dc7a4205ec
- Fix verify.md --changed exit code (0 → 2) - Add 'deep' as alias for 'thorough' in generation profile resolution - Fix PLUGIN_CONTRACTS_SPEC status: Partially implemented (registry done, runner pending) - Fix OUTBOUND_CONTRACT_MOCKING_SPEC status: Implemented (Phase 1) - Fix cli.md environment matrix to match actual code granularity - Fix chaos.md: document delay handler is currently a no-op - Fix getting-started.md warning: note APOPHIS does not proactively detect nondeterminism - Add variants section to getting-started.md - Build: clean | Tests: 849 pass, 0 fail
520 lines
18 KiB
Markdown
520 lines
18 KiB
Markdown
## Outbound Contract-Driven Mocking Spec
|
|
|
|
Status: Implemented (Phase 1)
|
|
|
|
Phase 1 (implemented): Schema parsing (`x-outbound`), mock runtime, imperative API (`enableOutboundMocks`, `getOutboundCalls`), fetch patching.
|
|
Phase 2 (pending): APOSTL extensions `outbound_calls(this)` and `outbound_last(this)` for contract assertions.
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
'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:
|
|
|
|
```ts
|
|
readonly outboundContracts?: Record<string, OutboundContractSpec>
|
|
```
|
|
|
|
### `TestConfig`
|
|
|
|
Add runner-level outbound mock control:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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.
|