trim: remove dead code and move large spec docs to attic

- Remove unused exports: renderProgress, formatTripleBoundaryCounterexample, clearCapturedRoutes
- Remove dead BUILTIN_PLUGIN_CONTRACTS constant (auto-registration removed earlier)
- Fix app-loader error messages to mention multiple export patterns
- Move to attic: protocol-extensions-spec, OUTBOUND_CONTRACT_MOCKING_SPEC, PLUGIN_CONTRACTS_SPEC, fastify-structure
- Build: clean | Tests: 849 pass, 0 fail
This commit is contained in:
John Dvorak
2026-04-30 12:47:40 -07:00
parent 115d3465b1
commit 5921b1437f
14 changed files with 7 additions and 120 deletions
@@ -0,0 +1,519 @@
## 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.
+428
View File
@@ -0,0 +1,428 @@
# APOPHIS Plugin Contract System Specification
## Status: Partially implemented
- Registry, types, and registration API: **implemented**
- Runner integration (merging plugin contracts into route execution): **pending**
- Built-in contracts for `@fastify/auth`, `@fastify/compress`, `@fastify/cors`, `@fastify/rate-limit`: **registered but not yet applied**
**Note**: Plugin contracts are complementary to Protocol Extensions (see `docs/protocol-extensions-spec.md`). Protocol extensions add domain-specific predicates (JWT, X.509, SPIFFE); plugin contracts add hook-phase behavioral contracts for Fastify plugins.
## 1. Overview
The Plugin Contract System enables Fastify plugins to declare APOPHIS contracts that are automatically merged into route contracts at test time. Plugins specify which hooks they participate in and what behavioral contracts they enforce at each phase of the request lifecycle.
**Key invariant**: Route contracts are the **composition** of route-level contracts plus all plugin contracts whose `appliesTo` pattern matches the route path.
## 2. Terminology
- **MUST**: Absolute requirement. Violation prevents system operation.
- **SHOULD**: Strong recommendation. Violation produces warnings.
- **MAY**: Optional capability. No impact if absent.
- **MUST NOT**: Prohibited behavior. Violation is a bug.
- **Plugin Contract**: A set of APOSTL expressions declared by a plugin, scoped to specific hook phases and route prefixes.
- **Phase Contract**: Contracts that apply at a specific point in the Fastify hook pipeline.
- **Contract Composition**: The merging of route-level and plugin-level contracts into a single testable set.
## 3. Architecture
### 3.1 Plugin Contract Declaration
Plugins declare contracts via the APOPHIS registry during registration:
```typescript
fastify.apophis.registerPluginContracts(name: string, spec: PluginContractSpec)
```
**File**: `src/plugin/index.ts` (NEW METHOD, line 130+)
### 3.2 Plugin Contract Specification
```typescript
interface PluginContractSpec {
/** Route path prefix pattern. Plugin contracts apply to routes matching this prefix.
* MUST support wildcards: '/api/**' matches '/api/users', '/api/users/:id'
* MUST default to '**' (all routes) if omitted
*/
appliesTo: string
/** Contracts organized by hook phase.
* MUST support: onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse
* MAY support additional phases as Fastify evolves
*/
hooks: {
[phase: string]: {
/** Preconditions that MUST hold before this phase executes */
requires?: string[]
/** Postconditions that MUST hold after this phase executes */
ensures?: string[]
}
}
/** Plugin metadata for diagnostics */
meta?: {
name?: string
version?: string
description?: string
}
}
```
**File**: `src/types.ts` (NEW SECTION after line 223)
### 3.3 Contract Registry
APOPHIS maintains an in-memory registry of plugin contracts:
```typescript
class PluginContractRegistry {
private contracts: Map<string, PluginContractSpec[]> = new Map()
/** Register a plugin's contract specification.
* MUST validate that appliesTo is a valid pattern.
* MUST reject duplicate registrations unless the new spec is byte-for-byte equivalent.
* SHOULD warn if a plugin declares contracts for phases it doesn't actually hook into.
*/
register(name: string, spec: PluginContractSpec): void
/** Find all plugin contracts that apply to a given route.
* MUST match appliesTo pattern against route.path.
* MUST return contracts from all matching plugins.
* MUST preserve plugin registration order in results.
*/
findContractsForRoute(route: RouteContract): Array<{ plugin: string; spec: PluginContractSpec }>
/** Merge route contracts with applicable plugin contracts.
* MUST deduplicate identical formulas.
* MUST preserve source attribution for diagnostics.
* MUST NOT mutate original route contracts.
*/
composeContracts(route: RouteContract): ComposedContract
}
```
**File**: `src/domain/plugin-contracts.ts` (NEW FILE)
### 3.4 Contract Composition
When APOPHIS tests a route, it composes contracts from all applicable sources:
```
ComposedContract = {
route: RouteContract,
phases: {
[phase: string]: {
requires: Array<{ formula: string; source: 'route' | 'plugin:name' }>
ensures: Array<{ formula: string; source: 'route' | 'plugin:name' }>
}
}
}
```
**Composition rules**:
- Route-level `x-requires``route` phase (handler execution)
- Route-level `x-ensures``route` phase (handler execution)
- Plugin `hooks[phase].requires` → respective phase
- Plugin `hooks[phase].ensures` → respective phase
- Phase `onRequest` contracts run before route handler
- Phase `onSend` contracts run after route handler but before response sent
- Phase `onResponse` contracts run after response fully sent
**File**: `src/domain/plugin-contracts.ts:80-120` (NEW)
### 3.5 Route Schema Integration
Routes MAY declare which plugins they expect via `x-plugins`:
```typescript
schema: {
'x-plugins': ['auth', 'rate-limit'],
'x-ensures': ['status:200'],
}
```
**Behavior**:
- If `x-plugins` is present, APOPHIS MUST warn if any listed plugin has no registered contracts.
- If `x-plugins` is present, APOPHIS MUST warn if a plugin's `appliesTo` doesn't match this route.
- If `x-plugins` is absent, APOPHIS MUST still apply all matching plugin contracts silently.
- `x-plugins` is for documentation and validation, not contract scoping.
**File**: `src/domain/contract.ts` (MODIFY `extractContract`, line 45+)
### 3.6 Built-in Plugin Contracts
APOPHIS MUST provide contract specifications for common Fastify plugins:
**File**: `src/plugins/built-in-contracts.ts` (NEW FILE)
```typescript
export const BUILTIN_PLUGIN_CONTRACTS: Record<string, PluginContractSpec> = {
'@fastify/auth': {
appliesTo: '**',
hooks: {
onRequest: {
requires: ['request_headers(this).authorization != null'],
},
},
},
'@fastify/compress': {
appliesTo: '**',
hooks: {
onSend: {
ensures: ['response_headers(this).content-encoding != null'],
},
},
},
'@fastify/cors': {
appliesTo: '**',
hooks: {
onRequest: {
ensures: ['response_headers(this).access-control-allow-origin != null'],
},
},
},
'@fastify/rate-limit': {
appliesTo: '**',
hooks: {
onRequest: {
ensures: [
'response_headers(this).x-ratelimit-limit != null',
'response_headers(this).x-ratelimit-remaining != null',
],
},
},
},
}
```
**Registration**:
- Built-in contracts MUST be registered automatically when APOPHIS plugin initializes.
- Built-in contracts MAY be overridden by explicit plugin registrations.
- Built-in contracts SHOULD be documented in `docs/PLUGIN_CONTRACTS_SPEC.md`.
**File**: `src/plugin/index.ts:48-69` (MODIFY initialization)
### 3.7 Test Runner Integration
The PETIT runner MUST compose contracts before executing each route:
**File**: `src/test/petit-runner.ts:180-190` (MODIFY)
```typescript
// Before generating commands, compose contracts for each route
const composedRoutes = routes.map(route => {
const composed = pluginContractRegistry.composeContracts(route)
// Warn if route declares x-plugins but plugin contracts don't match
const declaredPlugins = route.schema?.['x-plugins'] as string[] | undefined
if (declaredPlugins) {
for (const pluginName of declaredPlugins) {
const pluginContracts = pluginContractRegistry.findContractsForRoute(route)
.filter(c => c.plugin === pluginName)
if (pluginContracts.length === 0) {
console.warn(`Route ${route.method} ${route.path} declares plugin '${pluginName}' but no contracts match`)
}
}
}
return { ...route, composed }
})
```
### 3.8 Phase-Aware Contract Testing
APOPHIS MUST label each plugin contract with its phase. Exact phase execution is required only where hook interception is implemented:
**File**: `src/test/petit-runner.ts:250-300` (MODIFY execute loop)
```typescript
// For each command:
// 1. Test onRequest phase contracts (plugin only)
// 2. Execute request
// 3. Test route-level contracts (handler)
// 4. Test onSend/onResponse phase contracts (plugin only)
// Phase 1: onRequest contracts
if (composed.hooks?.onRequest?.requires) {
const preCtx = buildPreRequestContext(request)
validatePhaseContracts(composed.hooks.onRequest.requires, preCtx, route)
}
// Phase 2: Execute request (existing code)
ctx = await executeHttp(...)
// Phase 3: Route-level contracts (existing code)
validatePostconditions(composed.route.ensures, ctx, route)
// Phase 4: onSend/onResponse contracts
if (composed.hooks?.onSend?.ensures) {
validatePhaseContracts(composed.hooks.onSend.ensures, ctx, route)
}
```
**Note**: Phase-aware testing requires hook interception. Since Fastify doesn't expose hook execution points, APOPHIS MAY approximate by testing all plugin contracts against the final response context, with phase noted in diagnostics.
### 3.9 Diagnostics and Reporting
Contract violations MUST include source attribution:
```typescript
interface ContractViolation {
// ... existing fields ...
readonly source: 'route' | 'plugin:name'
readonly phase?: string // 'onRequest', 'onSend', etc.
}
```
**File**: `src/types.ts:170-192` (EXTEND ContractViolation)
Test results MUST show plugin contract coverage:
```typescript
interface TestSummary {
// ... existing fields ...
readonly pluginContractsApplied: number
readonly pluginContractsFailed: number
}
```
**File**: `src/types.ts:277-285` (EXTEND TestSummary)
## 4. API Surface
### 4.1 Plugin Registration
```typescript
// In plugin registration
fastify.apophis.registerPluginContracts('my-auth', {
appliesTo: '/api/**',
hooks: {
onRequest: {
requires: ['request_headers(this).authorization != null'],
},
},
})
```
**File**: `src/plugin/index.ts` (NEW METHOD)
### 4.2 Route Declaration
```typescript
fastify.get('/api/users', {
schema: {
'x-plugins': ['my-auth', '@fastify/rate-limit'],
'x-ensures': ['status:200', 'response_body(this).id != null'],
}
}, handler)
```
### 4.3 Test Execution
```typescript
const result = await fastify.apophis.contract({
depth: 'standard',
// Plugin contracts are applied automatically
})
// Results include plugin contract attribution
console.log(result.summary.pluginContractsApplied)
```
## 5. Implementation Plan
### Phase 1: Core Registry (2 hours)
**Files**:
- `src/types.ts:223+` — Add `PluginContractSpec`, `ComposedContract`, extend `ContractViolation`
- `src/domain/plugin-contracts.ts``PluginContractRegistry` class
- `src/plugin/index.ts:130+` — Add `registerPluginContracts()` method
- `src/plugin/index.ts:48-69` — Auto-register built-in contracts
**Tests**:
- `src/test/plugin-contracts.test.ts` — Registry operations, pattern matching, composition
### Phase 2: Route Integration (2 hours)
**Files**:
- `src/domain/contract.ts:45+` — Extract `x-plugins` from schema
- `src/test/petit-runner.ts:180-190` — Compose contracts before test generation
- `src/test/petit-runner.ts:250-300` — Apply composed contracts during execution
**Tests**:
- `src/test/plugin-contracts-integration.test.ts` — End-to-end plugin contract testing
### Phase 3: Built-in Contracts (1 hour)
**Files**:
- `src/plugins/built-in-contracts.ts` — Common plugin contracts
- `docs/PLUGIN_CONTRACTS_SPEC.md` — Documentation
**Tests**:
- `src/test/built-in-contracts.test.ts` — Verify built-in contracts load correctly
### Phase 4: Diagnostics (1 hour)
**Files**:
- `src/types.ts:277-285` — Extend `TestSummary` with plugin metrics
- `src/domain/contract-validation.ts` — Add source attribution to violations
- `src/test/error-renderer.ts` — Show plugin source in failure output
## 6. Invariants
### 6.1 Registry Invariants
- **I1**: Plugin contract registration MUST be idempotent. Registering the same plugin twice with identical spec MUST NOT throw.
- **I2**: Plugin contract registration order MUST be deterministic and preserved in diagnostics.
- **I3**: The registry MUST NOT mutate plugin specs after registration. All returned specs MUST be deep copies.
### 6.2 Composition Invariants
- **I4**: Contract composition MUST be deterministic. Same route + same plugins = same composed contract.
- **I5**: Contract composition MUST be idempotent. Composing an already-composed route MUST produce identical results.
- **I6**: Plugin contracts MUST NOT override route contracts. If route and plugin declare the same formula, route's version takes precedence.
### 6.3 Execution Invariants
- **I7**: Plugin contracts MUST be tested even if route has no `x-plugins` annotation. `x-plugins` is for validation, not scoping.
- **I8**: Plugin contract failures MUST include plugin name in diagnostics. Users MUST know which plugin's contract failed.
- **I9**: Plugin contract warnings (missing plugins, pattern mismatches) MUST NOT fail the test suite. They are informational only.
## 7. Backward Compatibility
- Routes without `x-plugins` still receive matching plugin contracts; this is additive validation behavior and must be called out in migration notes.
- Plugins without `registerPluginContracts()` MUST NOT cause errors.
- Existing `TestSuite` and `TestResult` types MUST remain compatible. New fields are optional.
## 8. Open Questions
1. **Phase interception**: Can we actually test onRequest/onSend contracts separately without monkey-patching Fastify? If not, we test all plugin contracts against final context with phase noted.
2. **Plugin versioning**: Should contracts include version constraints? e.g., `@fastify/auth@^4.0.0` contracts.
3. **Conditional contracts**: Should plugins declare contracts conditionally based on configuration? e.g., auth plugin with `optional: true` mode.
4. **Performance**: Composing contracts for 10K routes with 20 plugins. O(n*m) is acceptable but should be cached.
## 9. References
### Codebase Citations
- **Route discovery**: `src/domain/discovery.ts:26-32`
- **Hook validator**: `src/infrastructure/hook-validator.ts`
- **Contract extraction**: `src/domain/contract.ts:45+`
- **PETIT runner**: `src/test/petit-runner.ts:166-428`
- **Plugin entry**: `src/plugin/index.ts:48-69`
- **Types**: `src/types.ts:170-292`
### External References
- Fastify Hooks: https://www.fastify.io/docs/latest/Reference/Hooks/
- Fastify Plugin: https://github.com/fastify/fastify-plugin
- Fastify Encapsulation: https://www.fastify.io/docs/latest/Reference/Encapsulation/
---
*Document Version: 1.0*
*Author: APOPHIS Architecture Team*
*Date: 2026-04-25*
+459
View File
@@ -0,0 +1,459 @@
# Structuring Your Fastify App for APOPHIS
APOPHIS requires that you register its plugin **before** defining routes, and it needs to access your route schemas at test time. If your application is a single file that creates the server, connects to databases, registers routes, and starts listening, you cannot test it with APOPHIS.
This guide shows how to restructure a monolithic Fastify application into a testable plugin architecture.
---
## The Problem
Here is what Arbiter's setup looked like — a single file doing everything:
```typescript
// server.ts — THE WRONG WAY
import Fastify from 'fastify'
import database from './database'
import routes from './routes'
const fastify = Fastify()
// Database setup
await database.connect(process.env.DATABASE_URL)
// Register plugins
await fastify.register(require('@fastify/swagger'))
await fastify.register(require('@fastify/cors'))
// Register routes
fastify.register(routes)
// Add decorators
fastify.decorate('db', database)
// Start server
await fastify.listen({ port: 3000 })
```
**Why this breaks APOPHIS:**
1. **Routes are registered before APOPHIS** — APOPHIS must hook into the registration process, so it must be registered first.
2. **No way to create a test instance** — The database connection and server start are unconditional. You cannot create a second Fastify instance for testing without starting another server.
3. **No cleanup hook** — File system state (WAL logs, uploaded files) accumulates between runs.
4. **Side effects at import time** — Importing the file has side effects. You cannot import routes without importing the database connection.
---
## The Solution: App Factory Pattern
Separate **application creation** from **server startup**. Export a function that creates a configured Fastify instance without starting it.
### Recommended Directory Structure
```
src/
app.ts # App factory: creates Fastify instance, registers plugins
server.ts # Entry point: creates app, connects DB, starts server
plugins/
database.ts # Database connection plugin
auth.ts # Auth decorator plugin
routes/
users.ts # Route definitions with schema annotations
health.ts # Health check route
test/
setup.ts # Test bootstrap: creates app, registers APOPHIS
contracts.test.ts # Contract test entry point
```
### 1. App Factory (`src/app.ts`)
This file exports a function that creates a Fastify instance and registers all plugins **except** APOPHIS and the database connection. It should have no side effects.
```typescript
import Fastify from 'fastify'
import type { FastifyInstance } from 'fastify'
// Your application plugins
import databasePlugin from './plugins/database'
import authPlugin from './plugins/auth'
import userRoutes from './routes/users'
import healthRoutes from './routes/health'
export interface AppOptions {
// Pass configuration explicitly instead of reading env vars
databaseUrl?: string
jwtSecret?: string
enableLogging?: boolean
}
export async function buildApp(opts: AppOptions = {}): Promise<FastifyInstance> {
const fastify = Fastify({
logger: opts.enableLogging ?? true,
// Disable request logging in test mode to reduce noise
disableRequestLogging: process.env.NODE_ENV === 'test',
})
// Register infrastructure plugins
await fastify.register(databasePlugin, { url: opts.databaseUrl })
await fastify.register(authPlugin, { secret: opts.jwtSecret })
// Register route plugins
await fastify.register(userRoutes, { prefix: '/api/users' })
await fastify.register(healthRoutes, { prefix: '/health' })
return fastify
}
```
### 2. Database Plugin (`src/plugins/database.ts`)
Encapsulate database setup in a Fastify plugin. This makes it composable and testable.
```typescript
import fp from 'fastify-plugin'
import type { FastifyInstance } from 'fastify'
import { createConnection } from './db-client'
export interface DatabasePluginOptions {
url?: string
}
export default fp(async (fastify: FastifyInstance, opts: DatabasePluginOptions) => {
const db = await createConnection(opts.url ?? process.env.DATABASE_URL)
// Decorate fastify with db access
fastify.decorate('db', db)
// Clean up on close
fastify.addHook('onClose', async () => {
await db.disconnect()
})
})
```
### 3. Route Files with Contracts (`src/routes/users.ts`)
Define routes in separate files. Each route file is a Fastify plugin that receives the parent instance.
```typescript
import type { FastifyInstance } from 'fastify'
export default async function userRoutes(fastify: FastifyInstance) {
fastify.post('/', {
schema: {
'x-category': 'constructor',
'x-ensures': [
'status:201',
'response_body(this).id != null',
'response_body(this).email matches "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"',
],
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
const user = await fastify.db.users.create(req.body)
reply.status(201)
return user
})
fastify.get('/:id', {
schema: {
'x-category': 'observer',
'x-ensures': [
'if status:200 then response_body(this).id != null',
],
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
},
}, async (req, reply) => {
const user = await fastify.db.users.findById(req.params.id)
if (!user) {
reply.status(404)
return { error: 'Not found' }
}
return user
})
}
```
### 4. Production Entry Point (`src/server.ts`)
The production entry point imports the app factory, adds APOPHIS, connects to services, and starts the server.
```typescript
import { buildApp } from './app'
import apophis from 'apophis-fastify'
async function start() {
const fastify = await buildApp({
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
})
// Register APOPHIS before ready() but after all routes
await fastify.register(apophis, {
runtime: process.env.NODE_ENV === 'production' ? 'error' : 'warn',
timeout: 5000,
})
await fastify.ready()
// Start server
await fastify.listen({ port: Number(process.env.PORT) || 3000 })
console.log(`Server listening on ${fastify.server.address()}`)
}
start().catch((err) => {
console.error(err)
process.exit(1)
})
```
### 5. Test Bootstrap (`src/test/setup.ts`)
The test file creates a fresh app instance, registers APOPHIS, and runs contract tests against it.
```typescript
import { buildApp } from '../app'
import apophis from 'apophis-fastify'
import type { FastifyInstance } from 'fastify'
export async function createTestApp(): Promise<FastifyInstance> {
// Use test database or in-memory store
const fastify = await buildApp({
databaseUrl: process.env.TEST_DATABASE_URL ?? ':memory:',
jwtSecret: 'test-secret',
enableLogging: false,
})
// Register APOPHIS for testing
await fastify.register(apophis, {
timeout: 2000, // Faster timeouts in tests
cleanup: true, // Auto-cleanup resources
})
await fastify.ready()
return fastify
}
```
### 6. Contract Test Entry Point (`src/test/contracts.test.ts`)
```typescript
import { test } from 'node:test'
import assert from 'node:assert'
import { createTestApp } from './setup'
test('contract tests', async () => {
const fastify = await createTestApp()
try {
const result = await fastify.apophis.contract({
depth: 'standard',
seed: 42, // Deterministic
})
console.log(result.summary)
// Fail the test suite if any contract fails
assert.strictEqual(
result.summary.failed,
0,
`Contract failures: ${result.tests
.filter((t) => !t.ok)
.map((t) => t.name)
.join(', ')}`
)
} finally {
// Always clean up
await fastify.apophis.cleanup()
await fastify.close()
}
})
```
---
## Key Principles
### 1. No Side Effects at Import Time
**Wrong:**
```typescript
// db.ts
export const db = await createConnection(process.env.DATABASE_URL) // Side effect!
```
**Right:**
```typescript
// db.ts
export async function createDb(url: string) {
return createConnection(url)
}
```
### 2. Separate App Creation from Server Start
**Wrong:**
```typescript
// server.ts
const app = Fastify()
// ... setup ...
await app.listen({ port: 3000 }) // Cannot test without starting server
export default app
```
**Right:**
```typescript
// app.ts
export async function buildApp() {
const app = Fastify()
// ... setup without listen() ...
return app
}
// server.ts
const app = await buildApp()
await app.listen({ port: 3000 })
```
### 3. Use Fastify Plugins for Everything
Routes, database connections, auth, decorators — everything should be a Fastify plugin. This makes composition explicit and testable.
### 4. APOPHIS Registration Order
```typescript
// 1. Create app (registers routes)
const app = await buildApp()
// 2. Register APOPHIS (hooks into existing routes)
await app.register(apophis, opts)
// 3. Ready (compiles schemas)
await app.ready()
// 4. Test or serve
await app.apophis.contract({...})
// OR
await app.listen({...})
```
---
## Handling Arbiter-Specific Issues
### File System State (WAL Logs)
If your server writes to files or WAL logs:
```typescript
// test/setup.ts
import { mkdirSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
let testCounter = 0
export function createTestWorkspace() {
const dir = join(tmpdir(), `apophis-test-${++testCounter}`)
mkdirSync(dir, { recursive: true })
return {
path: dir,
cleanup() {
rmSync(dir, { recursive: true, force: true })
},
}
}
// In your test:
const workspace = createTestWorkspace()
const app = await buildApp({
dataDir: workspace.path, // Server writes here instead of production path
})
```
### Database Seeding
```typescript
// test/setup.ts
export async function seedTestDatabase(db: Database) {
await db.migrate.latest()
await db.seed.run()
}
// In your contract test:
const app = await createTestApp()
await seedTestDatabase(app.db)
```
### Complex Dependency Injection
If routes depend on external services (ledger, graph store):
```typescript
// Use test doubles via plugin options
export async function buildApp(opts: AppOptions) {
const app = Fastify()
// Production: real ledger
// Test: mock ledger
await app.register(ledgerPlugin, {
client: opts.ledgerClient ?? new RealLedgerClient(),
})
return app
}
```
---
## Migration Checklist
If you have a monolithic `server.ts` like Arbiter:
- [x] Extract route definitions into `src/routes/*.ts` files
- [x] Extract database/auth setup into `src/plugins/*.ts` files
- [x] Create `src/app.ts` with a `buildApp()` factory function
- [x] Move `fastify.listen()` from `app.ts` to `src/server.ts`
- [x] Create `src/test/setup.ts` that calls `buildApp()` + `apophis.register()`
- [x] Ensure no side effects at import time in any `src/` file
- [x] Run `npx tsc --noEmit` to verify no circular dependencies
- [x] Run contract tests: `npm run test:contracts`
---
## Summary
| Monolithic | Plugin Architecture |
|-----------|-------------------|
| Single file with everything | Factory function + plugin files |
| Side effects at import | Pure functions, explicit initialization |
| Cannot create test instance | Create unlimited instances |
| APOPHIS must be first (impossible) | APOPHIS registered after routes, before ready() |
| Manual cleanup | Hooks for automatic cleanup |
| Database URL hardcoded | Injected via options |
The plugin architecture takes 30 minutes to set up and saves hours of debugging when APOPHIS cannot access your routes.
+599
View File
@@ -0,0 +1,599 @@
# APOPHIS Protocol Extensions Specification
## Status: Active design; shipped baseline: v2.0.0; remaining targets listed per feature
## 1. Overview
This specification defines protocol-specific extensions for APOPHIS, driven by the Arbiter team's requirements for testing OAuth 2.1, WIMSE S2S, Transaction Tokens (RFC 8693), SPIFFE/SPIRE, and related security protocols.
APOPHIS is grounded in [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021). Protocol extensions add domain-specific predicates (JWT, X.509, SPIFFE) to the core invariant framework.
Arbiter maintains 58 protocol conformance test files covering 138 behaviors across 7 specifications. These extensions bridge the gap between declarative APOSTL contracts and the domain-specific predicates required for security protocol validation.
### 1.1 Current Shipped vs Not-Shipped Snapshot
**Shipped in v2.0.0:**
- `contract({ variants })` for multi-header/media negotiation execution.
- `fastify.apophis.scenario(...)` for multi-step capture/rebind flows.
- `response_payload(this)` for JSON/LDF semantic payload access.
- Chaos testing (`chaos` config) for resilience/failure-path validation.
- Extension registration API (`extensions` plugin option).
**Not shipped yet:**
- Route-level `x-variants` schema extraction.
Use the shipped foundations today. Route-level `x-variants` is follow-up work.
### 1.2 Extension Registration
Register extensions via the plugin options:
```javascript
await fastify.register(apophis, {
extensions: [
jwtExtension({ jwks: 'https://auth.example.com/.well-known/jwks.json' }),
x509Extension(),
spiffeExtension(),
tokenHashExtension()
]
});
```
Extensions are loaded at plugin registration time and validated before routes are processed.
### 1.3 x-variants Status
Route-level `x-variants` schema extraction is **not shipped** yet. Use call-site `contract({ variants })` instead:
```javascript
const suite = await fastify.apophis.contract({
depth: 'quick',
variants: [
{ name: 'json', headers: { accept: 'application/json' } },
{ name: 'ldf', headers: { accept: 'application/ld+json' } },
],
});
```
### 1.4 Protocol Packs Status
Built-in protocol pack presets are **shipped**. Reference them by name in `apophis.config.js`:
```javascript
export default {
packs: ['oauth21'],
// User profiles and presets override pack defaults
};
```
Available packs:
- `oauth21` — OAuth 2.1 authorization code flow with PKCE
- `rfc8628-device-auth` — Device Authorization Grant
- `rfc8693-token-exchange` — Token Exchange
Packs resolve during config loading and merge profiles/presets into the config. User config always takes precedence.
---
## 2. Design Principles
### 2.1 Extension Architecture
All protocol extensions follow the v1.1 extension architecture:
```javascript
await fastify.register(apophis, {
extensions: [
jwtExtension({ jwks: 'https://auth.example.com/.well-known/jwks.json' }),
x509Extension(),
spiffeExtension(),
tokenHashExtension()
]
});
```
### 2.2 Configuration Per Route
Routes may need different validation keys or extraction sources:
```javascript
fastify.get('/wimse/wit', {
schema: {
'x-category': 'observer',
'x-extension-config': {
jwt: { verify: false, extractFrom: 'body' }
},
'x-ensures': [
'jwt_claims(this).sub != null',
'jwt_claims(this).cnf.jwk != null'
]
}
});
```
### 2.3 Test Data Seeding
Stateful tests may need pre-existing resources:
```javascript
await fastify.apophis.seed([
{ method: 'POST', url: '/oauth/clients', body: { client_id: 'test-client' } },
{ method: 'POST', url: '/wimse/wit', body: { workload: 'frontend' } }
]);
const results = await fastify.apophis.stateful({ depth: 'standard' });
```
---
## 3. JWT Extension
### 3.1 Use Cases
OAuth 2.1, Transaction Tokens, WIMSE S2S, SPIFFE JWT-SVID
### 3.2 Predicates
```apostl
# Access JWT claims
jwt_claims(this).sub # subject
jwt_claims(this).aud # audience
jwt_claims(this).iss # issuer
jwt_claims(this).exp # expiration (numeric timestamp)
jwt_claims(this).iat # issued at (numeric timestamp)
jwt_claims(this).jti # JWT ID (for replay detection)
jwt_claims(this).scope # scope
jwt_claims(this).cnf.jwk # confirmation key (WIMSE)
jwt_claims(this).txn # transaction token ID
# Access JWT header
jwt_header(this).alg # algorithm
jwt_header(this).kid # key ID
jwt_header(this).typ # type
# Validation
jwt_valid(this) # signature verifies against known key
jwt_format(this) == "compact" # compact vs JSON serialization
```
### 3.3 Configuration
```javascript
jwtExtension({
jwks: 'https://auth.example.com/.well-known/jwks.json',
extractFrom: 'authorization',
verify: true,
})
```
### 3.4 Extension State
The JWT extension maintains state across a test run:
```javascript
/**
* JWT extension state across a test run.
* @property {Set<string>} seenJtis - Track seen JTIs for replay detection
* @property {Map<string, DecodedJwt>} decodedCache - Cached decoded JWTs
*/
const jwtExtensionState = {
seenJtis: new Set(),
decodedCache: new Map()
};
```
### 3.5 Example Contracts
```apostl
# OAuth 2.1: Token response contains required claims
if response_code(this) == 200 then jwt_claims(this).sub != null else T
if response_code(this) == 200 then jwt_claims(this).exp > jwt_claims(this).iat else T
# WIMSE: WPT expiration must be short-lived
if response_code(this) == 200 then jwt_claims(this).exp <= jwt_claims(this).iat + 30 else T
# Transaction Tokens: Token type must be transaction_token
if response_code(this) == 200 then jwt_claims(this).txn != null else T
```
### 3.6 Implementation Notes
- Decode Base64URL without verification for claim inspection
- Verify signatures using configured JWKS or key material
- Support extracting JWT from multiple sources
- Track `seen_jtis` for replay detection within a test run
---
## 4. Time Control Extension
### 4.1 Problem
Many protocol behaviors depend on time:
- Token expiration (JWT `exp` claim)
- Refresh token rotation windows
- WIMSE WPT short TTL (≤30 seconds)
- Challenge TTLs
Current limitation: APOSTL has `response_time(this)` (wall clock duration) but no way to compare JWT timestamps to "now" or fast-forward time.
### 4.2 Predicates
```apostl
# Compare JWT exp to current time (server time)
jwt_claims(this).exp > now()
jwt_claims(this).exp <= now() + 30
# Time since previous request
response_time(this) <= 5000 # already exists
elapsed_since_previous(this) <= 30 # new: seconds since last request in stateful test
```
### 4.3 Server-Level Time Mocking
```javascript
await fastify.register(apophis, {
timeMock: true // enables apophis.time control
});
// In tests or stateful sequences:
await fastify.apophis.time.advance(30000); // +30 seconds
await fastify.apophis.time.set('2026-04-25T12:00:00Z');
```
### 4.4 Implementation
```javascript
/**
* Time control for deterministic testing.
* @property {function(number): void} advance - Advance simulated time by milliseconds
* @property {function(string): void} set - Set simulated time to specific ISO timestamp
* @property {function(): number} now - Get current simulated time
* @property {function(): void} reset - Reset to real time
*/
const timeControl = {
advance(ms) { /* ... */ },
set(isoString) { /* ... */ },
now() { return Date.now(); },
reset() { /* ... */ }
};
```
The `now()` predicate returns simulated time when time mocking is enabled, or the host wall clock outside deterministic test mode. Deterministic runs must inject or freeze time.
### 4.5 DST Testing Example
```apostl
# Test that tokens issued before DST transition work after
if previous(jwt_claims(this).iat).hour == 1 then jwt_valid(this) == true else T
```
---
## 5. Stateful Cross-Request Predicates
### 5.1 Problem
Protocols have multi-step flows where step N depends on step N-1:
1. **OAuth 2.1 refresh token rotation:** First refresh succeeds and returns NEW token. Second refresh with OLD token fails.
2. **Transaction token single-use:** First consumption succeeds. Second consumption with same token fails.
3. **WIMSE WPT replay:** First verification succeeds. Second verification with same jti fails.
Current limitation: `previous()` only compares values, not state transitions.
### 5.2 Predicates
```apostl
# Check if token was seen in previous requests
already_seen(this, jwt_claims(this).jti) == false
# Check if token was consumed
is_consumed(this, jwt_claims(this).jti) == false
# Reference specific previous request by category
previous(constructor).jwt_claims(this).refresh_token # last constructor's refresh token
previous(mutator).jwt_claims(this).txn # last mutator's transaction token
previous(observer).jwt_claims(this).jti # last observer's JWT ID
```
### 5.3 Implementation
Extension state tracks tokens across requests:
```javascript
/**
* Stateful extension state tracking tokens across requests.
* @property {Set<string>} seenTokens - Tokens observed in previous requests
* @property {Set<string>} consumedTokens - Tokens that have been consumed
* @property {Map<string, EvalContext>} categoryHistory - category -> last context
*/
const statefulExtensionState = {
seenTokens: new Set(),
consumedTokens: new Set(),
categoryHistory: new Map()
};
```
### 5.4 Example Contracts
```apostl
# OAuth 2.1 refresh: new token must differ from old
if response_code(this) == 200 then
response_body(this).refresh_token != previous(request_body(this)).refresh_token
else T
# Transaction token: single use
if response_code(this) == 409 then
response_body(this).error == "transaction_token_replay_detected" &&
already_seen(this, jwt_claims(this).jti) == true
else T
```
---
## 6. X.509 Extension
### 6.1 Use Cases
SPIFFE X509-SVID, mTLS certificate validation
### 6.2 Predicates
```apostl
# Certificate properties
x509_uri_sans(this) # array of URI subject alternative names
x509_uri_sans(this).length # count of URI SANs
x509_ca(this) # is CA certificate? (boolean)
x509_expired(this) # is expired? (boolean)
x509_not_before(this) # notBefore timestamp
x509_not_after(this) # notAfter timestamp
# Chain validation (lightweight)
x509_self_signed(this) # is self-signed?
x509_issuer(this) # issuer DN
x509_subject(this) # subject DN
```
### 6.3 Explicitly Out of Scope
- `x509_chain_valid(this)` — APOPHIS does not implement RFC 5280 path validation. Applications may expose chain-validation results and test them as ordinary response behavior.
### 6.4 Example Contracts
```apostl
# SPIFFE: X509-SVID must have exactly 1 URI SAN
if response_code(this) == 200 then x509_uri_sans(this).length == 1 else T
# SPIFFE: X509-SVID leaf must not be CA
if response_code(this) == 200 then x509_ca(this) == false else T
# SPIFFE: Certificate must not be expired
if response_code(this) == 200 then x509_expired(this) == false else T
```
---
## 7. SPIFFE Extension
### 7.1 Use Cases
SPIFFE ID validation, trust domain checks
### 7.2 Predicates
```apostl
# SPIFFE ID parsing
spiffe_parse(this).trustDomain # trust domain string
spiffe_parse(this).path # path segments (array)
spiffe_parse(this).path.length # path depth
spiffe_validate(this) # boolean: valid SPIFFE ID?
# Properties
spiffe_id(this) # full SPIFFE ID string
spiffe_trust_domain(this) # alias for spiffe_parse(this).trustDomain
```
### 7.3 Example Contracts
```apostl
# SPIFFE: Trust domain must be lowercase
if response_code(this) == 200 then spiffe_parse(this).trustDomain matches "^[a-z0-9.-]+$" else T
# SPIFFE: Path must not be empty
if response_code(this) == 200 then spiffe_parse(this).path.length > 0 else T
# SPIFFE: ID must be valid
if response_code(this) == 200 then spiffe_validate(this) == true else T
```
---
## 8. Token Hash Extension
### 8.1 Use Cases
WIMSE S2S `ath` (access token hash), `tth` (transaction token hash), `oth` (other token hash)
### 8.2 Predicates
```apostl
# Token hash validation
ath_valid(this) # access token hash matches Authorization header
tth_valid(this) # transaction token hash matches Txn-Token header
oth_valid(this, "header-name") # custom token hash matches named header
# Raw hash computation
token_hash(this, "sha256") # SHA-256 hash of token from context
```
### 8.3 Example Contracts
```apostl
# WIMSE: If ath claim present, must match access token
if jwt_claims(this).ath != null then ath_valid(this) == true else T
# WIMSE: If tth claim present, must match transaction token
if jwt_claims(this).tth != null then tth_valid(this) == true else T
```
---
## 9. HTTP Signature Extension
### 9.1 Use Cases
WIMSE S2S detached HTTP signatures
### 9.2 Predicates
```apostl
# Signature components
signature_input(this) # Signature-Input header parsed
signature(this) # Signature header value
signature_valid(this) # signature verifies against key
# Coverage
signature_covers(this, "@method") # covers HTTP method
signature_covers(this, "@request-target") # covers request target
signature_covers(this, "authorization") # covers auth header
signature_covers(this, "txn-token") # covers txn-token header
```
### 9.3 Example Contracts
```apostl
# WIMSE: Signature must cover @method and @request-target
if response_code(this) == 200 then signature_covers(this, "@method") == true else T
if response_code(this) == 200 then signature_covers(this, "@request-target") == true else T
```
---
## 10. Request Context Extension
### 10.1 Predicates
```apostl
# URL components
request_url(this) # full URL
request_url(this).path # path only
request_url(this).host # host header
# TLS info (when available)
request_tls(this).cipher # TLS cipher suite
request_tls(this).version # TLS version
request_tls(this).client_cert # client certificate (if mTLS)
# Body hash (for content integrity)
request_body_hash(this, "sha256") # SHA-256 of raw request body
```
### 10.2 Example Contracts
```apostl
# WIMSE audience validation: WPT aud claim must match request URL
if response_code(this) == 200 then jwt_claims(this).aud == request_url(this) else T
```
---
## 11. Priority Matrix
| Feature | Impact | Effort | Priority | Protocols Needing It |
|---------|--------|--------|----------|---------------------|
| JWT extension (claims + validation) | Very High | Medium | **P0** | OAuth 2.1, WIMSE, Txn Tokens, SPIFFE |
| Time control (`now()`, `advance()`) | Very High | Medium | **P0** | OAuth 2.1, WIMSE, Txn Tokens, CIBA |
| Stateful predicates (`previous()`, `already_seen()`) | High | Medium | **P1** | OAuth 2.1, Txn Tokens, WIMSE |
| X.509 extension (basic properties) | High | Low | **P1** | SPIFFE, WIMSE |
| SPIFFE extension | Medium | Low | **P2** | SPIFFE |
| Token hash extension | Medium | Low | **P2** | WIMSE |
| HTTP signature extension | Medium | Medium | **P2** | WIMSE |
| Request context (`request_url()`) | Medium | Low | **P2** | WIMSE |
| Parallel execution | Low | High | **P3** | — |
---
## 12. Protocol Test Inventory
| Protocol | Test File | Behaviors | Needs Extensions |
|----------|-----------|-----------|------------------|
| OAuth 2.1 | `oauth21-profile-conformance.test.js` | 13 | JWT, time control |
| WIMSE S2S | `draft-wimse-s2s-protocol-conformance.test.js` | 31 | JWT, token hash, HTTP sig, X.509 |
| Transaction Tokens | `draft-oauth-transaction-tokens-conformance.test.js` | 25 | JWT, time control, stateful |
| SPIFFE/SPIRE | `spiffe-spire-conformance.test.js` | 24 | SPIFFE, X.509, JWT |
| Token Exchange | `rfc8693-token-exchange-conformance.test.js` | 15 | JWT |
| Device Auth | `rfc8628-device-authorization-conformance.test.js` | 12 | JWT |
| CIBA | `ciba-conformance.test.js` | 18 | JWT, time control |
**Total: 138 protocol behaviors across 7 specifications.**
---
## 13. Out of Scope
We acknowledge these are too complex or inappropriate for Apophis:
| Feature | Why Out of Scope |
|---------|-----------------|
| Replay detection across restarts | Cross-run replay detection requires application-owned persistent state. |
| Full X.509 chain validation | Requires trust store, CRL/OCSP, and policy validation. Applications may expose the result for APOPHIS to check. |
| Cryptographic algorithm implementation | Apophis should not implement crypto. It should verify signatures using existing libraries. |
| Protocol state machines | Full state-machine extraction is still out of scope at route-schema level, but protocol flows are supported through `fastify.apophis.scenario(...)` and can be combined with `contract({ variants })` and APOSTL formulas. |
| Network-level testing | TCP behavior, packet inspection, MTU issues. Out of scope for HTTP contract testing. |
| Parallel execution for race detection | Can be tested with separate load testing tools. Not essential for contract testing. |
---
## 14. Implementation Plan
### Phase 1: JWT + Time Control (P0) — Shipped in v2.0.0
**Status**: Complete
**Files**:
- `src/extensions/jwt.ts` — JWT extension implementation
- `src/extensions/time.ts` — Time control extension
- `src/extensions/stateful.ts` — Stateful predicates extension
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
- `src/test/cli/protocol-conformance-p2.test.ts` — Protocol conformance tests
**Tests**:
- Decode Base64URL claims without verification
- Verify signatures against JWKS
- Extract from multiple sources (header, body, query)
- `seen_jtis` replay detection
- `now()` predicate with mocked time
- `apophis.time.advance()` in stateful tests
### Phase 2: X.509 + SPIFFE (P1) — Shipped in v2.0.0
**Status**: Complete
**Files**:
- `src/extensions/x509.ts` — X.509 extension
- `src/extensions/spiffe.ts` — SPIFFE extension
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
### Phase 3: Token Hash + HTTP Signature (P2) — Shipped in v2.0.0
**Status**: Complete
**Files**:
- `src/extensions/token-hash.ts` — Token hash extension
- `src/extensions/http-signature.ts` — HTTP signature extension
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
### Phase 4: Request Context (P2) — Shipped in v2.0.0
**Status**: Complete
**Files**:
- `src/extensions/request-context.ts` — Request context predicates
- `src/test/protocol-extensions.test.ts` — Protocol extension tests
---
## 15. References
### Codebase Citations
- **Extension architecture**: `docs/extensions/EXTENSION-ARCHITECTURE.md`
- **Extension types**: `src/extension/types.ts`
- **Extension registry**: `src/extension/registry.ts`
- **Formula parser**: `src/formula/parser.ts`
- **Test runner**: `src/test/petit-runner.ts`
### External References
- JWT RFC 7519: https://tools.ietf.org/html/rfc7519
- WIMSE S2S: https://datatracker.ietf.org/doc/draft-ietf-wimse-s2s-protocol/
- Transaction Tokens (RFC 8693): https://tools.ietf.org/html/rfc8693
- SPIFFE/SPIRE: https://spiffe.io/
- OAuth 2.1: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/
---
*Document Version: 1.0*
*Author: APOPHIS Architecture Team*
*Date: 2026-04-25*
*Source Feedback: docs/attic/root-history/FEEDBACK-protocol-extensions-wishlist.md*