fix: harden engine, enrich failure diagnostics, close adoption gaps

- P0: CLI verify now honors  test budget with seeded multi-sample
- P0: Observe sampling enforced via Math.random() gate in hook-validator
- P1: Remove misleading undici-mock-agent isolation option
- P1: Qualify reuses shared discoverRouteDetails() with warnings
- P1: Chaos/scenario config exposed via preset schema
- P1: README/docs limitations updated to current state
- P2: Nested response annotations prefer 2xx deterministically
- P2: --changed documented as heuristic in verify.md

- Add observe sink tests (sampling 0/1, sink failure non-interference)
- Add verify runs regression tests (scale, determinism, variants)
- Add configured-scenario qualify test (independent of OAuth fixture)
- Add coverageBreakdown to qualify artifacts (per-gate route coverage)
- Add production-style observe example with real sink in docs/observe.md
- Add nightly/staging vs PR gating guidance to docs/qualify.md

- Enrich VerifyFailure with formula-aware diagnostics:
  status:201 => 'HTTP 200', body field checks => actual values
- Remove stale observe CLI activation message
- Document outbound mocks as process-global in getting-started.md
- Refresh APOPHIS_ADOPTION_AUDIT.md with current state

903 tests pass, build clean, typecheck clean.
This commit is contained in:
John Dvorak
2026-05-21 20:39:36 -07:00
parent 55b0262799
commit d0523fcc2d
128 changed files with 4004 additions and 3631 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Five independent interface reviews (Substack/minimalist, Jared Hanson/DX, WebRef
### Package Entry Point
```typescript
import apophis from 'apophis-fastify'
import apophis from '@apophis/fastify'
```
The package exports one default: the Fastify plugin. No `export * from './types'`.
+1 -1
View File
@@ -136,7 +136,7 @@ This section should be short and visual.
Show exactly three commands:
```bash
npm install apophis-fastify fastify @fastify/swagger
npm install @apophis/fastify fastify @fastify/swagger
apophis init --preset safe-ci
apophis verify --profile quick --routes "POST /users"
```
+1 -1
View File
@@ -132,7 +132,7 @@ Catch behavioral regressions before merge with minimal setup.
Journey:
1. The team installs `apophis-fastify` and `@fastify/swagger`.
1. The team installs `@apophis/fastify` and `@fastify/swagger`.
2. The team runs `apophis init --preset safe-ci`.
3. The CLI scaffolds a small config file, example route guidance, and a package script.
4. The team adds one `x-ensures` contract to one critical route.
+25 -6
View File
@@ -4,9 +4,28 @@ Archived design/planning documents that are no longer canonical for day-to-day u
Use `README.md` and `docs/getting-started.md` for current behavior and API guidance.
Archived items:
- `docs/attic/API_REDESIGN_V1.md`
- `docs/attic/QUALITY_FEATURES_PLAN.md`
- `docs/attic/extensions/AUTH-RATE-LIMIT.md`
- `docs/attic/extensions/WEBSOCKETS.md`
- `docs/attic/root-history/` (historical feedback, plans, assessments, and analysis notes moved from repo root)
Top-level:
- `API_REDESIGN_V1.md` — original API surface redesign proposal
- `BLOAT_ASSESSMENT.md` — codebase bloat audit notes
- `CLI_EXECUTION_GUIDE.md` — early CLI invocation walkthrough
- `chaos-v2.md` — chaos engine v2 design notes
- `adoption-certification-scorecard.md` — adoption readiness scoring rubric
- `fastify-structure.md` — Fastify integration architecture notes
- `GITHUB_SITE_STRATEGY.md` — GitHub Pages / site planning
- `homepage.md` — homepage content draft
- `MULTI_FRAMEWORK_FEASIBILITY.md` — multi-framework support feasibility study
- `OUTBOUND_CONTRACT_MOCKING_SPEC.md` — outbound contract mocking specification
- `PLUGIN_CONTRACTS_SPEC.md` — plugin/contract interface specification
- `protocol-extensions-spec.md` — protocol extension mechanism specification
- `PUBLIC_INTERFACE_REDESIGN.md` — public API surface redesign
- `QUALITY_FEATURES_PLAN.md` — quality feature roadmap
- `TEST_AUDIT_REPORT.md` — test coverage audit report
- `testing-pyramid.md` — testing pyramid / strategy notes
`extensions/`:
- `AUTH-RATE-LIMIT.md` — original auth + rate-limit extension spec
- `AUTH-RATE-LIMIT-REVISED.md` — revised auth + rate-limit design
- `HTTP-EXTENSIONS.md` — HTTP extension mechanism notes
- `WEBSOCKETS.md` — WebSocket extension design
`root-history/` — historical feedback, plans, assessments, and analysis notes moved from repo root (25 files)
@@ -78,7 +78,7 @@ Rate each dimension from **1 (poor)** to **5 (excellent)**.
| Dimension | Rating (1-5) | Evidence / Notes |
|-----------|--------------|------------------|
| Setup friction | 5 | `npm install apophis-fastify` + `npx apophis init` + `npx apophis verify` — three commands to first value. |
| Setup friction | 5 | `npm install @apophis/fastify` + `npx apophis init` + `npx apophis verify` — three commands to first value. |
| Time-to-first-value | 5 | Default `depth: 'quick'` runs in seconds. Immediate feedback on route contracts. |
| CI confidence | 4 | `verify` in CI with `--format json-summary` gives pass/fail gate. Artifact retention allows post-hoc debugging. |
| Replay reliability | 5 | `--replay` is single copy-paste command. Seed is printed in every failure. |
@@ -131,7 +131,7 @@ Attach the following to this scorecard:
### Setup (all personas)
```bash
npm install apophis-fastify
npm install @apophis/fastify
npx apophis --help # exits 0
npx apophis init # writes scaffold
npx apophis doctor # passes
+1 -1
View File
@@ -71,7 +71,7 @@ await fastify.apophis.contract({
Wrap a `fetch` implementation so outbound requests are intercepted:
```javascript
import { wrapFetch, createOutboundInterceptor } from 'apophis-fastify'
import { wrapFetch, createOutboundInterceptor } from '@apophis/fastify'
const interceptor = createOutboundInterceptor([
{
@@ -1,7 +1,7 @@
# APOPHIS v1.0 — Authentication, Authorization & Rate Limiting Extension (REVISED)
> **Status: NOT IMPLEMENTED**
> This document describes a proposed extension that is not yet available in APOPHIS. The predicates, types, and infrastructure described here do not exist in the current codebase. Use `createAuthExtension` from `apophis-fastify/extension/factories` for auth testing today.
> This document describes a proposed extension that is not yet available in APOPHIS. The predicates, types, and infrastructure described here do not exist in the current codebase. Use `createAuthExtension` from `@apophis/fastify/extension/factories` for auth testing today.
## 1. Overview
@@ -767,7 +767,7 @@ const contract: RouteContract = {
```typescript
import fastify from 'fastify'
import { apophisPlugin } from 'apophis-fastify'
import { apophisPlugin } from '@apophis/fastify'
const app = fastify()
+1 -1
View File
@@ -1230,7 +1230,7 @@ async cleanup(authContext?: AuthContext): Promise<Array<{ resource: TrackedResou
```typescript
import fastify from 'fastify'
import { apophisPlugin } from 'apophis-fastify'
import { apophisPlugin } from '@apophis/fastify'
const app = fastify()
+2 -2
View File
@@ -219,7 +219,7 @@ Using `@fastify/websocket`:
```typescript
import fastify from 'fastify'
import websocket from '@fastify/websocket'
import apophis from 'apophis-fastify'
import apophis from '@apophis/fastify'
const app = fastify()
@@ -1410,7 +1410,7 @@ const registerWebSocketValidation = (
```typescript
import fastify from 'fastify'
import websocket from '@fastify/websocket'
import apophis from 'apophis-fastify'
import apophis from '@apophis/fastify'
const app = fastify()
+2 -2
View File
@@ -202,7 +202,7 @@ The production entry point imports the app factory, adds APOPHIS, connects to se
```typescript
import { buildApp } from './app'
import apophis from 'apophis-fastify'
import apophis from '@apophis/fastify'
async function start() {
const fastify = await buildApp({
@@ -236,7 +236,7 @@ The test file creates a fresh app instance, registers APOPHIS, and runs contract
```typescript
import { buildApp } from '../app'
import apophis from 'apophis-fastify'
import apophis from '@apophis/fastify'
import type { FastifyInstance } from 'fastify'
export async function createTestApp(): Promise<FastifyInstance> {
+1 -1
View File
@@ -77,7 +77,7 @@ Production outages often come from behavior drift as well as invalid payload sha
Three commands to the first targeted behavior check:
```bash
npm install apophis-fastify fastify @fastify/swagger
npm install @apophis/fastify fastify @fastify/swagger
apophis init --preset safe-ci
apophis verify --profile quick --routes "POST /users"
```
+3 -3
View File
@@ -750,7 +750,7 @@ async function apophisPlugin(fastify, options) {
}
module.exports = fp(apophisPlugin, {
name: 'apophis-fastify',
name: '@apophis/fastify',
dependencies: ['@fastify/swagger']
})
```
@@ -1879,7 +1879,7 @@ class ApophisSymbolicAnalyzer {
## File Structure
```
apophis-fastify/
@apophis/fastify/
├── lib/
│ ├── formula-parser.js # APOSTL formula parsing and evaluation
│ ├── formula-substitutor.js # Safe parameter substitution
@@ -1916,7 +1916,7 @@ apophis-fastify/
const fastify = require('fastify')()
// Register APOPHIS (registers @fastify/swagger automatically)
await fastify.register(require('apophis-fastify'), {
await fastify.register(require('@apophis/fastify'), {
swagger: {
openapi: '3.0.0',
info: { title: 'Tournaments API', version: '1.0.0' }
@@ -271,4 +271,4 @@ As it stands, APOPHIS is a promising research project that teaches us a lot abou
*Assessment by Charity Majors, co-founder Honeycomb.io*
*Date: 2026-04-25*
*Framework: apophis-fastify v1.1.0*
*Framework: @apophis/fastify v1.1.0*
@@ -12,14 +12,14 @@ A complete "Hello World" to "Production Ready" guide that a developer can follow
#### 1.1 Installation (30 seconds)
```bash
npm install apophis-fastify
npm install @apophis/fastify
# peer deps: fastify, @fastify/swagger
```
#### 1.2 Minimal Setup (2 minutes)
```typescript
import Fastify from 'fastify'
import apophisPlugin from 'apophis-fastify'
import apophisPlugin from '@apophis/fastify'
const fastify = Fastify()
@@ -358,7 +358,7 @@ jobs:
#### 3.4 Cache Configuration API
```typescript
// Programmatic control
import { invalidateRoutes, invalidateCache } from 'apophis-fastify/incremental/cache'
import { invalidateRoutes, invalidateCache } from '@apophis/fastify/incremental/cache'
// Before test run
invalidateRoutes(['/users']) // Invalidate specific routes
+2 -2
View File
@@ -6,10 +6,10 @@ APOPHIS generates requests automatically. For authenticated routes, you need to
## The Pattern: `createAuthExtension`
Use `createAuthExtension` from `apophis-fastify` to inject credentials into every request:
Use `createAuthExtension` from `@apophis/fastify` to inject credentials into every request:
```javascript
import { createAuthExtension } from 'apophis-fastify'
import { createAuthExtension } from '@apophis/fastify'
const jwtAuth = createAuthExtension({
name: 'jwt',
+1 -1
View File
@@ -2,7 +2,7 @@
Inject controlled failures into contract tests to validate resilience guarantees.
Chaos testing applies the invariant-driven verification approach from [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021) under adverse conditions: if a contract must hold, it should still hold when dependencies fail, responses are delayed, or payloads are corrupted.
Chaos testing applies invariant-driven verification under adverse conditions: if a contract must hold, it should still hold when dependencies fail, responses are delayed, or payloads are corrupted.
## Usage
+14 -17
View File
@@ -45,9 +45,8 @@ apophis init --preset safe-ci
**Examples:**
```bash
apophis init --preset safe-ci
apophis init --preset llm-safe --force
apophis init --preset platform-observe --noninteractive
<!-- smoke-test -->
apophis init --help
```
### `apophis verify`
@@ -70,10 +69,8 @@ apophis verify --profile quick --routes "POST /users"
**Examples:**
```bash
apophis verify --profile quick
apophis verify --routes "POST /users" --seed 42
apophis verify --changed
apophis verify --profile ci --routes "POST /users,PUT /users/*"
<!-- smoke-test -->
apophis verify --help
```
**Machine output for CI:**
@@ -104,8 +101,8 @@ apophis observe --profile staging-observe
**Examples:**
```bash
apophis observe --profile staging-observe
apophis observe --check-config
<!-- smoke-test -->
apophis observe --help
```
### `apophis qualify`
@@ -124,8 +121,8 @@ apophis qualify --profile oauth-nightly --seed 42
**Examples:**
```bash
apophis qualify --profile oauth-nightly --seed 42
apophis qualify --profile lifecycle-deep
<!-- smoke-test -->
apophis qualify --help
```
### `apophis replay`
@@ -144,7 +141,8 @@ apophis replay --artifact reports/apophis/failure-2026-04-28T12-30-22Z.json
**Examples:**
```bash
apophis replay --artifact reports/apophis/failure-*.json
<!-- smoke-test -->
apophis replay --help
```
### `apophis doctor`
@@ -173,8 +171,8 @@ apophis doctor [--mode verify|observe|qualify] [--strict]
**Examples:**
```bash
apophis doctor
apophis doctor --verbose
<!-- smoke-test -->
apophis doctor --help
```
### `apophis migrate`
@@ -194,9 +192,8 @@ apophis migrate --check
**Examples:**
```bash
apophis migrate --check
apophis migrate --dry-run
apophis migrate --write
<!-- smoke-test -->
apophis migrate --help
```
## Common Tasks
+141
View File
@@ -0,0 +1,141 @@
import Fastify from 'fastify'
import apophisPlugin from '@apophis/fastify'
import crypto from 'crypto'
const fastify = Fastify()
import { createAuthExtension } from '@apophis/fastify/extension/factories'
const authExtension = createAuthExtension({
name: 'bearer',
acquire: async () => {
// In real apps, this would call a login endpoint
const token = crypto.randomBytes(32).toString('hex')
return { token, userId: 'tester-1' }
},
})
await fastify.register(apophisPlugin, {
runtime: 'error',
extensions: [authExtension],
})
const sessions = new Map<string, { userId: string; createdAt: number }>()
// LOGIN — acquires a session token
fastify.post('/auth/login', {
schema: {
'x-category': 'constructor',
'x-ensures': [
'response_body(this).token != null',
'response_body(this).expiresAt > request_time(this)',
],
body: {
type: 'object',
properties: {
username: { type: 'string' },
password: { type: 'string' },
},
required: ['username', 'password'],
},
response: {
200: {
type: 'object',
properties: {
token: { type: 'string' },
expiresAt: { type: 'number' },
},
},
},
},
}, async (req) => {
const token = crypto.randomBytes(48).toString('hex')
const expiresAt = Date.now() + 3600_000
sessions.set(token, { userId: `usr-${req.body.username}`, createdAt: Date.now() })
return { token, expiresAt }
})
// PROTECTED RESOURCE — requires valid auth
fastify.get('/auth/me', {
schema: {
'x-category': 'observer',
'x-requires': [
'response_status(this) == 200',
],
'x-ensures': [
'response_body(this).userId != null',
'response_body(this).authenticated == true',
],
headers: {
type: 'object',
properties: {
authorization: { type: 'string', pattern: '^Bearer ' },
},
required: ['authorization'],
},
response: {
200: {
type: 'object',
properties: {
userId: { type: 'string' },
authenticated: { type: 'boolean' },
},
},
},
},
}, async (req, reply) => {
const header = req.headers.authorization
if (!header) {
reply.status(401)
return { error: 'Missing Authorization header' }
}
const token = header.replace('Bearer ', '')
const session = sessions.get(token)
if (!session) {
reply.status(401)
return { error: 'Invalid or expired token' }
}
return { userId: session.userId, authenticated: true }
})
// LOGOUT — destroys a session, must not succeed twice
fastify.post('/auth/logout', {
schema: {
'x-category': 'destructor',
'x-requires': [
'response_status(this) == 200',
],
'x-ensures': [
// After logout, the same token should be rejected
'response_code(GET /auth/me) == 401',
],
headers: {
type: 'object',
properties: {
authorization: { type: 'string', pattern: '^Bearer ' },
},
required: ['authorization'],
},
},
}, async (req, reply) => {
const header = req.headers.authorization
if (!header) {
reply.status(401)
return { error: 'Missing Authorization header' }
}
const token = header.replace('Bearer ', '')
const existed = sessions.delete(token)
if (!existed) {
reply.status(404)
return { error: 'Session not found' }
}
return { ok: true }
})
await fastify.ready()
const result = await fastify.apophis.contract({ runs: 30 })
console.log('Contract tests:', result.summary)
const stateful = await fastify.apophis.stateful({ runs: 30, seed: 42 })
console.log('Stateful tests:', stateful.summary)
+6 -3
View File
@@ -1,5 +1,5 @@
import Fastify from 'fastify'
import apophisPlugin from 'apophis-fastify'
import apophisPlugin from '@apophis/fastify'
import crypto from 'crypto'
const fastify = Fastify()
@@ -20,8 +20,11 @@ fastify.post('/users', {
schema: {
'x-category': 'constructor',
'x-ensures': [
// Round-trip: the server returns exactly what we sent (no mutation, no drops)
'response_body(this) == request_body(this)',
// The response must have an id assigned by the server
'response_body(this).id != null',
// The echoed fields must match the request
'response_body(this).email == request_body(this).email',
'response_body(this).name == request_body(this).name',
// Cross-route: the created user must be retrievable
'response_code(GET /users/{response_body(this).id}) == 200',
],
+179
View File
@@ -0,0 +1,179 @@
import Fastify from 'fastify'
import apophisPlugin from '@apophis/fastify'
import crypto from 'crypto'
const fastify = Fastify()
await fastify.register(apophisPlugin, {
runtime: 'error',
cleanup: true,
})
const processedKeys = new Set<string>()
const ledger = new Map<string, { idempotencyKey: string; amount: number; createdAt: number }>()
// PAYMENT — idempotent, must not double-charge
fastify.post('/payments', {
schema: {
'x-category': 'mutator',
'x-requires': [
'request_header(this).idempotency-key != null',
],
'x-ensures': [
'response_status(this) == 201 || response_status(this) == 200',
'response_body(this).id != null',
// Core idempotency assurance: same key, same result
'response_body(this).amount == request_body(this).amount',
],
headers: {
type: 'object',
properties: {
'idempotency-key': { type: 'string' },
},
required: ['idempotency-key'],
},
body: {
type: 'object',
properties: {
amount: { type: 'number', minimum: 1 },
currency: { type: 'string', minLength: 3, maxLength: 3 },
},
required: ['amount', 'currency'],
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
idempotencyKey: { type: 'string' },
amount: { type: 'number' },
status: { type: 'string' },
},
},
200: {
type: 'object',
properties: {
id: { type: 'string' },
idempotencyKey: { type: 'string' },
amount: { type: 'number' },
status: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
const key = req.headers['idempotency-key'] as string
// Idempotency check: if already processed, return the saved result
if (processedKeys.has(key)) {
for (const record of ledger.values()) {
if (record.idempotencyKey === key) {
return { ...record, status: 'duplicate' }
}
}
}
const id = `pay-${crypto.randomUUID().slice(0, 8)}`
const record = {
id,
idempotencyKey: key,
amount: req.body.amount,
status: 'processed',
createdAt: Date.now(),
}
processedKeys.add(key)
ledger.set(id, record)
reply.status(201)
return record
})
// REFUND — idempotent reversal
fastify.post('/payments/:id/refund', {
schema: {
'x-category': 'mutator',
'x-requires': [
'response_code(GET /payments/{request_params(this).id}) == 200',
],
'x-ensures': [
'response_body(this).refundId != null',
'response_body(this).amount == previous(response_body(GET /payments/{request_params(this).id})).amount',
// Refund must be reversible only once
],
headers: {
type: 'object',
properties: {
'idempotency-key': { type: 'string' },
},
required: ['idempotency-key'],
},
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
response: {
201: {
type: 'object',
properties: {
refundId: { type: 'string' },
amount: { type: 'number' },
status: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
const payment = ledger.get(req.params.id)
if (!payment) {
reply.status(404)
return { error: 'Payment not found' }
}
const key = req.headers['idempotency-key'] as string
if (processedKeys.has(key)) {
return {
refundId: `ref-${payment.id}`,
amount: payment.amount,
status: 'duplicate',
}
}
processedKeys.add(key)
reply.status(201)
return {
refundId: `ref-${payment.id}`,
amount: payment.amount,
status: 'refunded',
}
})
// PAYMENT STATUS — observer
fastify.get('/payments/:id', {
schema: {
'x-category': 'observer',
'x-requires': [
'response_status(this) == 200 || response_status(this) == 404',
],
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
},
}, async (req, reply) => {
const payment = ledger.get(req.params.id)
if (!payment) {
reply.status(404)
return { error: 'Payment not found' }
}
return payment
})
await fastify.ready()
const result = await fastify.apophis.contract({ runs: 50 })
console.log('Contract tests:', result.summary)
const stateful = await fastify.apophis.stateful({ runs: 50, seed: 42 })
console.log('Stateful tests:', stateful.summary)
+1 -1
View File
@@ -1,5 +1,5 @@
import Fastify from 'fastify'
import apophisPlugin from 'apophis-fastify'
import apophisPlugin from '@apophis/fastify'
const fastify = Fastify()
+2 -2
View File
@@ -89,7 +89,7 @@ type PredicateResolver = (context: PredicateContext) =>
## 4. Example: Arbiter Extension
```typescript
import type { ApophisExtension, PredicateContext } from 'apophis-fastify'
import type { ApophisExtension, PredicateContext } from '@apophis/fastify'
import { createArbiter } from 'arbiter-sdk'
const arbiterExtension: ApophisExtension = {
@@ -263,7 +263,7 @@ const arbiterExtension: ApophisExtension = {
```typescript
import fastify from 'fastify'
import apophis from 'apophis-fastify'
import apophis from '@apophis/fastify'
import { arbiterExtension } from './arbiter-extension.js'
const app = fastify()
+29 -38
View File
@@ -46,23 +46,26 @@ await fastify.apophis.contract({
})
```
### wrapFetch for Outbound Interception
### Outbound Mocking
Use `fastify.apophis.test.enableOutboundMocks()` in test code to mock HTTP dependencies:
```typescript
import { wrapFetch, createOutboundInterceptor } from 'apophis-fastify'
const interceptor = createOutboundInterceptor([
{
target: 'api.stripe.com',
delay: { probability: 0.1, minMs: 1000, maxMs: 5000 },
error: {
probability: 0.05,
responses: [{ statusCode: 429, headers: { 'retry-after': '60' } }]
}
fastify.apophis.test.registerOutboundContracts({
'payment-api': {
target: 'https://api.stripe.com/v1',
method: 'POST',
response: { 200: { type: 'object', properties: { id: { type: 'string' } } } }
}
], 42)
})
fastify.apophis.test.enableOutboundMocks({ mode: 'example' })
const calls = fastify.apophis.test.getOutboundCalls('payment-api')
```
const interceptedFetch = wrapFetch(globalThis.fetch, interceptor)
Programmatic access via `createOutboundMockRuntime`:
```typescript
import { createOutboundMockRuntime } from '@apophis/fastify'
```
### Mutation Testing
@@ -70,7 +73,7 @@ const interceptedFetch = wrapFetch(globalThis.fetch, interceptor)
Measure contract strength by injecting synthetic bugs:
```typescript
import { runMutationTesting } from 'apophis-fastify/quality/mutation'
import { runMutationTesting } from '@apophis/fastify/quality/mutation'
const report = await runMutationTesting(fastify)
console.log(`Score: ${report.score}%`) // 0-100
@@ -190,7 +193,7 @@ Extensions register custom APOSTL predicates that can be used in `x-ensures` and
**Register via `extensions: [sseExtension]`**
```typescript
import { sseExtension } from 'apophis-fastify/extensions/sse'
import { sseExtension } from '@apophis/fastify/extensions/sse'
await fastify.register(apophis, {
extensions: [sseExtension]
@@ -236,7 +239,7 @@ sse_events(this).0.retry // number (ms)
**Register via `extensions: [createSerializerExtension(registry)]`**
```typescript
import { createSerializerExtension, createSerializerRegistry } from 'apophis-fastify/extensions/serializers'
import { createSerializerExtension, createSerializerRegistry } from '@apophis/fastify/extensions/serializers'
const registry = createSerializerRegistry()
registry.register('protobuf', {
@@ -273,7 +276,7 @@ fastify.post('/users', {
**Register via `extensions: [websocketExtension]`**
```typescript
import { websocketExtension } from 'apophis-fastify/extensions/websocket'
import { websocketExtension } from '@apophis/fastify/extensions/websocket'
await fastify.register(apophis, {
extensions: [websocketExtension]
@@ -320,7 +323,7 @@ ws_state(this) // string
**Register via `extensions: [jwtExtension(config)]`**
```typescript
import { jwtExtension } from 'apophis-fastify/extensions'
import { jwtExtension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [
@@ -348,7 +351,7 @@ jwt_format(this) == "compact"
**Register via `extensions: [x509Extension(config)]`**
```typescript
import { x509Extension } from 'apophis-fastify/extensions'
import { x509Extension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [x509Extension()]
@@ -370,7 +373,7 @@ x509_self_signed(this) == false
**Register via `extensions: [spiffeExtension(config)]`**
```typescript
import { spiffeExtension } from 'apophis-fastify/extensions'
import { spiffeExtension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [spiffeExtension()]
@@ -391,7 +394,7 @@ spiffe_validate(this) == true
**Register via `extensions: [tokenHashExtension(config)]`**
```typescript
import { tokenHashExtension } from 'apophis-fastify/extensions'
import { tokenHashExtension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [tokenHashExtension()]
@@ -412,7 +415,7 @@ token_hash(this, "sha256") == jwt_claims(this).ath
**Register via `extensions: [httpSignatureExtension(config)]`**
```typescript
import { httpSignatureExtension } from 'apophis-fastify/extensions'
import { httpSignatureExtension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [httpSignatureExtension()]
@@ -433,7 +436,7 @@ signature_valid(this) == true
**Register via `extensions: [timeExtension(config)]`**
```typescript
import { timeExtension } from 'apophis-fastify/extensions'
import { timeExtension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [timeExtension()]
@@ -453,7 +456,7 @@ jwt_claims(this).exp <= now() + 30000
**Register via `extensions: [statefulExtension()]`**
```typescript
import { statefulExtension } from 'apophis-fastify/extensions'
import { statefulExtension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [statefulExtension()]
@@ -521,7 +524,7 @@ fastify.get('/tenants/:id', {
**Register via `extensions: [requestContextExtension(config)]`**
```typescript
import { requestContextExtension } from 'apophis-fastify/extensions'
import { requestContextExtension } from '@apophis/fastify/extensions'
await fastify.register(apophis, {
extensions: [requestContextExtension()]
@@ -555,19 +558,7 @@ await fastify.apophis.contract({
### Outbound Interception
```typescript
import { wrapFetch, createOutboundInterceptor } from 'apophis-fastify'
const interceptor = createOutboundInterceptor([{
target: 'api.stripe.com',
error: {
probability: 0.05,
responses: [{ statusCode: 429, headers: { 'retry-after': '60' } }]
}
}], 42)
const interceptedFetch = wrapFetch(globalThis.fetch, interceptor)
```
Outbound interception works through `fastify.apophis.test.enableOutboundMocks()` in test code. See the [Outbound Mocking](#outbound-mocking) section for the supported API.
### Per-Route Overrides
+9 -5
View File
@@ -2,23 +2,25 @@
Get from install to your first behavioral bug in 10 minutes.
APOPHIS is inspired by [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021): instead of only validating request and response shape, encode intended behavior as executable contracts and let the tool find violations automatically.
APOPHIS is inspired by the concept of invariant-driven automated testing: instead of only validating request and response shape, encode intended behavior as executable contracts and let the tool find violations automatically.
## Prerequisites
- Node.js 20.x or 22.x
- **Fastify v5** (v4 is not supported)
- **ESM project** (`"type": "module"` in package.json)
- A Fastify app with `@fastify/swagger` registered
## Step 1: Install
```bash
npm install apophis-fastify fastify @fastify/swagger
npm install @apophis/fastify fastify @fastify/swagger
```
## Step 2: Scaffold
```bash
apophis init --preset safe-ci
npx apophis init --preset safe-ci
```
This creates:
@@ -55,7 +57,7 @@ app.post('/users', {
## Step 4: Run Verify
```bash
apophis verify --profile quick --routes "POST /users"
npx apophis verify --profile quick --routes "POST /users"
```
## Example Failure
@@ -232,7 +234,7 @@ APOPHIS reads these OpenAPI schema extensions:
| `x-validate-runtime` | Top-level or `response[statusCode]` | Toggle runtime validation for this route (default: true) |
| `x-extension-config` | Top-level | Per-route config for extensions (e.g., `{ jwt: { verify: false } }`) |
Annotations can be placed on the top-level schema or nested inside `response[statusCode]`. Nested annotations take precedence for that status code.
Annotations can be placed on the top-level schema or nested inside `response[statusCode]`. Nested annotations from the first status code schema are merged with top-level annotations.
## Programmatic API
@@ -268,6 +270,8 @@ fastify.apophis.test.disableOutboundMocks()
const calls = fastify.apophis.test.getOutboundCalls('payment-api')
```
Outbound mocking patches `globalThis.fetch` and is process-global. Only one mock runtime can be installed at a time. Run mock-dependent tests serially or isolate by process.
## Config Reference
For the full configuration reference, see [CLI Reference](cli.md).
+2 -2
View File
@@ -2,7 +2,7 @@
APOPHIS is designed to be safe and predictable for LLM-generated Fastify services.
It applies the invariant-driven approach from [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021) to LLM-assisted development: constrained vocabulary, deterministic replay, and executable contracts give coding agents a verifiable loop between generated changes and behavioral correctness.
It applies an invariant-driven approach to LLM-assisted development: constrained vocabulary, deterministic replay, and executable contracts give coding agents a verifiable loop between generated changes and behavioral correctness.
## Why APOPHIS Is Good for LLM-Generated Services
@@ -164,7 +164,7 @@ console.log(verifyResult);
2. **Run doctor first**: Catch setup issues before running verify.
3. **Use `--changed` in CI**: Only verify routes that changed in the PR.
4. **Commit config**: Store `apophis.config.js` in version control.
5. **Pin versions**: Pin `apophis-fastify` version in `package.json`.
5. **Pin versions**: Pin `@apophis/fastify` version in `package.json`.
## Troubleshooting
+64 -15
View File
@@ -2,16 +2,11 @@
Runtime visibility and drift detection without blocking by default.
Observe extends the invariant framework from [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021) to production environments: contracts run continuously against live traffic to detect behavioral drift without affecting requests.
APOPHIS observe has two paths:
## What Observe Does
1. **CLI `apophis observe`**: Validates observe configuration readiness (policy, sinks, sampling, safety boundaries). Introduces no service process or runtime hooks. Use this for CI config validation before deployment.
`apophis observe` validates your runtime observe configuration:
1. Checks that observe mode is allowed in the current environment
2. Validates reporting sink setup (logs, metrics, traces)
3. Confirms non-blocking semantics
4. Reports what would be observed and why it is safe
2. **Programmatic runtime observation**: Register the APOPHIS plugin with `observe.enabled: true` and `observe.sinks` to emit contract pass/violation/error events from live traffic without blocking responses. Sampling controls the fraction of observed requests.
## When to Use It
@@ -164,18 +159,72 @@ export default {
};
```
## Sink Endpoint Configuration
## Programmatic Runtime Activation
Configure the reporting sink endpoint in your observe config:
The CLI only validates configuration. To activate runtime observation, register
APOPHIS with observe options in your application:
```javascript
observe: {
sink: {
endpoint: 'http://collector.internal:4318'
}
```typescript
import Fastify from 'fastify'
import apophisPlugin from '@apophis/fastify'
const app = Fastify({ logger: true })
// Register APOPHIS with observe enabled.
// This emits non-blocking contract pass/violation/error events
// for every covered request, gated by sampling.
await app.register(apophisPlugin, {
runtime: 'warn',
observe: {
enabled: true,
sampling: 0.1, // observe 10% of requests
sinks: [metricsSink],
},
})
// Implement the ObserveSink interface.
// Capture events to your preferred observability backend.
import type { ObserveSink, ObserveEvent } from '@apophis/fastify'
const metricsSink: ObserveSink = {
emit(event: ObserveEvent) {
// Emit a counter for each contract evaluation
myMetrics.increment(`apophis.contract.${event.type}`, {
route: event.route,
formula: event.formula,
})
// Record duration as a histogram
myMetrics.histogram('apophis.contract.duration_ms', event.durationMs, {
route: event.route,
})
// Log high-signal violations for immediate triage
if (event.type === 'contract.violation') {
logger.warn({ event }, 'APOPHIS contract violation')
}
},
}
```
Key constraints:
- Sink `emit()` can be sync or async (returns `void | Promise<void>`).
- Sink rejections and thrown errors are silently caught — they never affect the route response or status code.
- Sampling is applied per-formula evaluation via `Math.random() < sampling`.
At `sampling: 1` every formula is emitted. At `sampling: 0` nothing is emitted.
- Only routes with APOPHIS annotations (`x-ensures`, `x-requires`) produce events.
Routes without annotations are not evaluated in observe mode.
## Sink Implementations
APOPHIS does not ship with built-in sinks. The `ObserveSink` interface lets you
plug in any backend. Common patterns:
- **OpenTelemetry**: emit counters and histograms via `@opentelemetry/api`.
- **pino logger**: emit structured log records via `pino.info()` / `pino.warn()`.
- **Internal metrics service**: POST events to an internal collector endpoint.
- **In-memory ring buffer**: capture recent events for diagnostics endpoints.
## Monorepo Validation
For monorepos, use `apophis doctor --workspace` to validate observe configuration across all workspace packages. `observe` itself does not support `--workspace`; use `doctor` to check config in each package.
+245 -5
View File
@@ -2,7 +2,7 @@
Run scenario, stateful, and chaos checks against non-production Fastify services.
Qualify extends the invariant-driven approach from [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021) with multi-step protocol flows, stateful sequences, and controlled fault injection.
Qualify extends invariant-driven testing with multi-step protocol flows, stateful sequences, and controlled fault injection.
## What Qualify Does
@@ -15,9 +15,51 @@ Qualify extends the invariant-driven approach from [Invariant-Driven Automated T
## When to Use It
- **Nightly CI**: Scenario and stateful checks for critical flows
- **Staging**: Protocol flow validation before production
- **Specialist teams**: Auth, billing, workflow systems
Qualify is heavier than verify. Use it where the depth is worth the runtime cost:
| Workflow | Recommended | Why |
|---|---|---|
| **Pull request** | No — use `verify` | `verify` is fast (<5s for typical services) and catches behavioral regressions per-route. Qualify adds multi-minute scenario/stateful/chaos runs that are too slow for PR feedback loops. |
| **Nightly** | Yes | Full scenario, stateful, and chaos execution against staging. Catch protocol-level regressions that single-route verification cannot see. |
| **Pre-release** | Yes | Run qualify against the exact artifact that will be promoted to production. Treat a passing qualify run as a release gate for critical flows. |
| **Specialist workflows** | Yes | Auth flows, billing sequences, idempotency guarantees, and pagination consistency need multi-step qualification that verify cannot express. |
| **Chaos engineering** | Nightly or ad-hoc | Chaos injection increases latency. Run it in dedicated CI slots, not on every commit. |
### Quick workflow setup
```javascript
// apophis.config.js — two profiles for different cadences
export default {
mode: 'qualify',
profiles: {
'nightly': {
name: 'nightly',
mode: 'qualify',
preset: 'deep',
features: ['scenario', 'stateful', 'chaos'],
routes: [],
},
'pre-release': {
name: 'pre-release',
mode: 'qualify',
preset: 'deep',
features: ['scenario', 'stateful'],
routes: [],
},
},
presets: {
deep: { timeout: 15000, chaos: false },
},
}
```
Run nightly: `apophis qualify --profile nightly`
Run pre-release: `apophis qualify --profile pre-release --format json-summary`
For pull requests, use verify instead:
```bash
apophis verify --profile ci
```
## Scenario Examples
@@ -246,7 +288,205 @@ export default {
## Gate Execution Counts
Human output shows per-gate execution counts (scenario, stateful, chaos, adversity) so you can verify which gates actually ran.
Human output shows per-gate execution counts (scenario, stateful, chaos) so you can verify which gates actually ran.
## Custom Scenarios (config-defined)
Define arbitrary multi-step scenarios directly in your `apophis.config.js` without writing code:
```javascript
// apophis.config.js
export default {
mode: 'qualify',
scenarios: [
{
name: 'idempotency-check',
steps: [
{
name: 'create-order',
request: {
method: 'POST',
url: '/orders',
body: { product: 'widget', quantity: 3 },
},
expect: ['status:201', 'response_body(this).id != null'],
capture: { orderId: 'response_body(this).id' },
},
{
name: 'duplicate-create',
request: {
method: 'POST',
url: '/orders',
headers: { 'x-idempotency-key': 'dup-001' },
body: { product: 'widget', quantity: 3 },
},
expect: ['status:200', 'response_body(this).id == "$create-order.orderId"'],
},
],
},
{
name: 'pagination-flow',
steps: [
{
name: 'list-page-1',
request: { method: 'GET', url: '/items?page=1&limit=5' },
expect: ['status:200', 'response_body(this).items != null'],
capture: { firstPageCount: 'response_body(this).items.length' },
},
{
name: 'list-page-2',
request: { method: 'GET', url: '/items?page=2&limit=5' },
expect: ['status:200'],
},
],
},
],
profiles: {
'nightly': {
name: 'nightly',
mode: 'qualify',
preset: 'deep',
routes: ['POST /orders', 'GET /orders', 'GET /items'],
},
},
presets: {
deep: { name: 'deep', timeout: 15000, chaos: true },
},
environments: {
local: { name: 'local', allowQualify: true, allowChaos: true },
},
};
```
Scenario step fields:
| Field | Required | Description |
|---|---|---|
| `name` | yes | Human-readable step label |
| `request.method` | yes | HTTP method (GET, POST, PUT, DELETE, PATCH) |
| `request.url` | yes | URL path (e.g. `/orders`, `/items?page=1`) |
| `request.body` | no | JSON request body |
| `request.headers` | no | Custom headers (e.g. `x-idempotency-key`) |
| `expect` | yes | APOSTL formulas that must return truthy for step to pass |
| `capture` | no | Map of `{ key: "apostl_formula" }` — captured values are substituted via `$stepName.key` in later steps |
Captured values are interpolated in subsequent step URLs, bodies, and headers using `$stepName.key` syntax.
## Chaos Configuration
Fine-tune chaos behavior via preset fields:
```javascript
presets: {
'chaos-lab': {
name: 'chaos-lab',
timeout: 10000,
chaos: true,
chaosStrategy: 'sample', // 'one' | 'all' | 'sample' | 'routes'
chaosSampleSize: 5, // routes to sample when strategy = 'sample'
chaosSampleRoutes: [ // explicit routes when strategy = 'routes'
'GET /api/users',
'POST /api/orders',
],
},
}
```
| Field | Default | Description |
|---|---|---|
| `chaosStrategy` | `'one'` | Route selection strategy |
| `chaosSampleSize` | `3` | Routes to sample (strategy `sample`) |
| `chaosSampleRoutes` | — | Explicit route list (strategy `routes`) |
## Artifact Interpretation
Each qualify run produces an artifact JSON document. Key sections:
### executionSummary
```json
{
"executionSummary": {
"totalPlanned": 15,
"totalExecuted": 12,
"totalPassed": 10,
"totalFailed": 2,
"scenariosRun": 3,
"statefulTestsRun": 5,
"chaosRunsRun": 4,
"chaosRoutesPlanned": 2,
"chaosRoutesExecuted": 2,
"totalSteps": 12
}
}
```
Use `totalExecuted` vs `totalPlanned` to see how many checks actually ran (gate gating, route filtering, chaos selection). A non-zero `totalPlanned` with zero `totalExecuted` means all gates were disabled or no routes matched.
### executedRoutes / skippedRoutes
```json
{
"executedRoutes": ["POST /orders", "GET /orders/:id", "GET /items"],
"skippedRoutes": [
{ "route": "DELETE /items/:id", "reason": "No scenario covers this route" },
{ "route": "GET /health", "reason": "Not selected by chaos strategy: one" }
]
}
```
`executedRoutes` lists every route that had at least one scenario step, stateful command, or chaos injection. `skippedRoutes` explains why every other discovered route was excluded.
### profileGates
```json
{
"profileGates": {
"scenario": true,
"stateful": true,
"chaos": false
}
}
```
Shows which gates were active. Combine with `executionSummary` per-gate counts to verify each active gate produced results.
### stepTraces
Each entry records an individual step execution:
```json
{
"stepTraces": [
{
"step": 0,
"name": "create-order",
"route": "POST /orders",
"durationMs": 12,
"status": "passed"
}
]
}
```
Filter by `status` to isolate failures. Look at `durationMs` for performance regressions.
### failures
```json
{
"failures": [
{
"route": "POST /orders",
"contract": "status:201",
"category": "runtime",
"replayCommand": "apophis replay --artifact reports/apophis/qualify-2026-05-21T...json"
}
]
}
```
`replayCommand` gives a copy-pasteable command to re-run the exact same seed with the stored artifact for triage.
## Zero-Execution Guardrail
+4 -4
View File
@@ -44,7 +44,7 @@ import {
applyChaosToExecution,
createChaosEventArbitrary,
formatChaosEvents,
} from 'apophis-fastify'
} from '@apophis/fastify'
// Apply pre-generated chaos events to a context
const result = applyChaosToExecution(ctx, events)
@@ -71,7 +71,7 @@ Automatically rerun failing tests with varied seeds to detect non-deterministic
### Usage
```javascript
import { FlakeDetector } from 'apophis-fastify'
import { FlakeDetector } from '@apophis/fastify'
const detector = new FlakeDetector({
sameSeedReruns: 1, // Rerun with same seed
@@ -121,7 +121,7 @@ Measure contract strength by injecting synthetic bugs. A "mutation" is a small c
### Usage
```javascript
import { runMutationTesting } from 'apophis-fastify/quality/mutation'
import { runMutationTesting } from '@apophis/fastify/quality/mutation'
const report = await runMutationTesting(fastify, {
runs: 10,
@@ -176,7 +176,7 @@ console.log('Weak contracts:', report.weakContracts)
Test a specific mutation without running the full suite:
```javascript
import { testMutation } from 'apophis-fastify/quality/mutation'
import { testMutation } from '@apophis/fastify/quality/mutation'
const killed = await testMutation(fastify, contract, mutation, {
runs: 10,
+6 -2
View File
@@ -2,7 +2,7 @@
Deterministic contract verification for CI and local development.
APOPHIS implements the invariant-driven approach from [Invariant-Driven Automated Testing](https://arxiv.org/abs/2602.23922) (Malhado Ribeiro, 2021): encode intended behavior as executable formulas, then verify them automatically with property-based generation and deterministic replay.
APOPHIS implements invariant-driven testing: encode intended behavior as executable formulas, then verify them automatically with property-based generation and deterministic replay.
## When to Use It
@@ -83,6 +83,8 @@ apophis verify --profile ci --changed
If no routes changed, exits 2 with a message.
`--changed` is a heuristic: it maps changed file paths to routes by checking route path segments against file names. This is useful as a developer convenience, but for strict CI gating, prefer explicit `--routes` filters or full verification.
## Failure Output Format
When a contract fails, APOPHIS prints:
@@ -186,7 +188,7 @@ Output includes per-package pass/fail summaries. Fails if any package fails.
## Test Budget
The `runs` field in your preset controls how many property-based tests execute per route. Default is 50. Lower for faster CI feedback, higher for deeper exploration:
The `runs` field in your preset controls how many property-based test samples execute per route. Default is 50. Lower for faster CI feedback, higher for deeper exploration:
```javascript
profiles: {
@@ -203,3 +205,5 @@ presets: {
}
}
```
CLI verify generates one property-based test sample per contract by default when no `runs` is specified. Set `runs` in the preset to increase sampled inputs per route.