Files
apophis-fastify/docs/OUTBOUND_CONTRACT_MOCKING_SPEC.md
T

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

  1. Let routes declare outbound dependency contracts once and reuse them anywhere.
  2. Generate contract-conformant outbound mock responses from JSON Schema.
  3. Apply chaos at the dependency layer, before application code receives the response.
  4. Record outbound calls so tests and contracts can inspect them.
  5. Work in both APOPHIS contract tests and imperative E2E tests.
  6. Reuse existing chaos, fast-check, and flake-detection infrastructure.
  7. Avoid service-specific adapters and avoid a second testing engine.

Non-Goals

  1. No Stripe-specific or service-specific code in APOPHIS.
  2. No second DSL for outbound expectations.
  3. No new backward-compatibility layer for old chaos config.
  4. No static JS analysis in this cut. The design must enable it later, not implement it now.

Parsimony Rules

  1. One schema annotation: x-outbound.
  2. One shared registry: outboundContracts.
  3. One runtime owner: OutboundMockRuntime.
  4. One fetch interception path: reuse wrapFetch() and createOutboundInterceptor() instead of inventing another chaos stack.
  5. One property-generation engine: reuse convertSchema() for dependency responses instead of creating a second generator pipeline.
  6. One seeded randomness model: derive outbound mock randomness from the same test seed via sub-seeds, never Date.now().
  7. 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:

  1. One-off dependencies can be inline.
  2. Shared dependencies can be referenced by name.
  3. Route-local chaos overrides are possible without duplicating the shared contract.
  4. 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:

  1. many valid caller requests to the route
  2. many valid dependency responses allowed by the outbound contract
  3. 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:

  1. runner resolves the route's x-outbound bindings
  2. bindings are normalized against the shared registry
  3. an OutboundMockRuntime is installed for the duration of that request execution
  4. globalThis.fetch is wrapped in test mode for the duration of execution
  5. outbound calls matching a resolved contract return generated or overridden responses
  6. the existing outbound chaos layer decorates those responses
  7. call traces are recorded
  8. after the request completes, fetch is restored
  9. the recorded outbound facts are attached to the eval context for formulas and diagnostics

For imperative E2E:

  1. test calls enableOutboundMocks()
  2. runtime installs fetch patch once
  3. test drives the app normally
  4. test inspects getOutboundCalls()
  5. 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:

  1. false disables outbound mocking even if routes declare x-outbound.
  2. mode: 'example' returns one contract-conformant response per dependency and is the default.
  3. mode: 'property' samples across documented dependency responses.
  4. 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:

  1. RouteContract: lines 28-42
  2. TestConfig: lines 256-264
  3. ChaosConfig: lines 308-355
  4. ApophisOptions: lines 498-519
  5. ApophisDecorations: lines 612-627

Modify:

  1. Add outbound?: readonly OutboundBinding[] to RouteContract at lines 28-42.
  2. Add OutboundContractSpec, OutboundBinding, ResolvedOutboundContract, and OutboundCallRecord near existing outbound chaos types at lines 266-356.
  3. Add outboundMocks?: false | { ... } to TestConfig at lines 256-264.
  4. Add outboundContracts?: Record<string, OutboundContractSpec> to ApophisOptions at lines 498-519.
  5. Add registerOutboundContracts, enableOutboundMocks, disableOutboundMocks, and getOutboundCalls to ApophisDecorations at lines 612-627.

Keep it parsimonious:

  1. Do not add a second chaos config type.
  2. Do not add separate inline-vs-reference types if a discriminated union on x-outbound handles both.
  3. Keep OutboundContractSpec JSON-Schema-centric so convertSchema() 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:

  1. Parse schema['x-outbound'].
  2. Normalize string refs and inline objects into RouteContract.outbound.
  3. Preserve current caching behavior in contractCache.

Keep it parsimonious:

  1. Normalize shape here once.
  2. Do not resolve references here because this module does not own plugin-level registries.
  3. 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:

  1. Instantiate an OutboundContractRegistry and OutboundMockRuntime next to existing registries.
  2. Register opts.outboundContracts during plugin setup.
  3. Add imperative outbound mock decorations.
  4. Register a built-in outbound extension that exposes outbound_calls(this) and outbound_last(this).

Keep it parsimonious:

  1. Do not create a separate plugin or extension package for outbound support.
  2. 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:

  1. Pass through opts.outboundMocks.

Keep it parsimonious:

  1. No logic here beyond forwarding config.

5. src/plugin/stateful-builder.ts

Current anchor: lines 13-29 build the config passed into runStatefulTests().

Modify:

  1. Pass through opts.outboundMocks.
  2. Keep parity with contract-builder.ts.

6. src/test/petit-runner.ts

Current anchors:

  1. lines 237-243 function signature
  2. line 322 constructs EnhancedChaosEngine
  3. lines 342-430 build request and execute the route
  4. lines 497-512 attach chaos diagnostics

Modify:

  1. Resolve route.outbound against the shared registry before execution.
  2. Install OutboundMockRuntime around the single route execution.
  3. If chaosEngine.executeWithChaos() returns outboundInterceptor, compose it into the runtime instead of inventing a second path.
  4. Attach outbound call trace into diagnostics and eval context.
  5. In property mode, expand outbound response scenarios using convertSchema(..., { context: 'response' }) and deterministic seeds.

Keep it parsimonious:

  1. Do not create a second runner.
  2. Do not fork request generation logic; augment the existing execution loop.
  3. Reuse the runner seed and SeededRng instead of introducing local randomness.

7. src/test/stateful-runner.ts

Current anchors:

  1. line 25 imports legacy ChaosEngine from ../quality/chaos.js
  2. line 62 stores chaosEngine?: ChaosEngine
  3. lines 272-279 execute with chaos
  4. line 394 constructs new ChaosEngine(config.chaos, config.seed)

Modify:

  1. Migrate stateful testing to EnhancedChaosEngine from src/quality/chaos-v2.ts.
  2. Install the same outbound mock runtime used by petit-runner.ts.
  3. Use the same outbound scenario generation rules so stateful and contract runners do not diverge.

Keep it parsimonious:

  1. Do not maintain two chaos stacks.
  2. Do not implement outbound mocking twice.

8. src/quality/chaos-v2.ts

Current anchors:

  1. lines 52-125 wrapFetch()
  2. lines 148-188 seed management and outbound interceptor construction
  3. lines 214-274 route execution and outbound interceptor attachment

Modify:

  1. Keep wrapFetch() as the only fetch interception primitive.
  2. Add a small composition helper if needed so OutboundMockRuntime can run mock response -> outbound chaos overlay -> Response.
  3. Keep per-route chaos resolution in buildOutboundInterceptor().

Keep it parsimonious:

  1. Do not move mock generation into chaos-v2.
  2. Chaos owns chaos, not contract generation.

9. src/quality/chaos-outbound.ts

Current anchor: lines 36-105 create the pure outbound interceptor.

Modify:

  1. No structural redesign required.
  2. Ensure the interceptor remains transport-agnostic and can wrap both real fetch and mock responders.

Keep it parsimonious:

  1. This file should stay pure.
  2. Do not add runtime registry logic here.

10. src/domain/schema-to-arbitrary.ts

Current anchor: lines 214-217 export convertSchema().

Modify:

  1. Reuse convertSchema(responseSchema, { context: 'response' }) for generated dependency responses.
  2. Add a tiny helper for weighted status-code sampling if needed, but do not fork the schema conversion logic.

Keep it parsimonious:

  1. No second schema generator.
  2. 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:

  1. Public API can stay unchanged if outbound runtime derives every sub-seed from the rerun seed.
  2. If diagnostics are added, include outbound scenario seed in rerun metadata, but do not add a separate flake engine.

Keep it parsimonious:

  1. Flake support should come from determinism, not from more feature flags.

12. New files

Add only these new files:

  1. src/domain/outbound-contracts.ts
    • normalize, resolve, and validate x-outbound bindings against the shared registry
  2. src/infrastructure/outbound-mock-runtime.ts
    • install and restore fetch patch
    • record calls
    • resolve overrides
    • return generated or overridden responses
  3. 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:

  1. route declares outbound contract
  2. runtime resolves contract
  3. runtime generates or overrides mock response
  4. existing outbound chaos interceptor applies delay/error/dropout if configured
  5. 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:

  1. route command generation already depends on config.seed
  2. outbound response generation must derive from that same seed
  3. per-contract sampling must use stable sub-seeds, e.g. hashCombine(seed, stableHash(contractName))
  4. 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:

  1. mode: 'example' uses one generated success response per dependency plus explicit override cases.
  2. This is enough to support contract tests and imperative E2E immediately.

Phase 2:

  1. mode: 'property' samples across every documented outbound status code.
  2. For each sampled dependency response, APOPHIS executes the route and checks route postconditions.
  3. 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:

  1. src/test/domain.test.ts
    • extractContract() parses x-outbound string refs
    • extractContract() parses inline outbound contracts
    • route-local chaos overrides are preserved
  2. 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
  3. src/test/outbound-interceptor.test.ts
    • existing outbound chaos still works when wrapping a mock executor
  4. src/test/integration.test.ts
    • fastify.apophis.contract() with x-outbound exercises dependency-layer failures
    • enableOutboundMocks() supports imperative E2E style
  5. src/test/stateful-runner.test.ts or new stateful integration tests
    • stateful runner uses the same outbound runtime and chaos path
  6. src/test/flake.test.ts new
    • same seed gives same outbound responses and same call trace
    • different seeds explore different dependency outputs without nondeterministic drift

Migration Guidance

For Arbiter:

  1. move the Stripe contract definitions out of src/server/server/services/StripeFetchAdapter.js
  2. register them once via outboundContracts
  3. change route schemas to use x-outbound
  4. 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

  1. static analysis of whether a route contract can be satisfied for all permitted dependency responses
  2. detection of impossible route postconditions before running property tests
  3. 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.