chore: crush git history - reborn from consolidation on 2026-03-10

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
commit d278c4b105
313 changed files with 87549 additions and 0 deletions
+982
View File
@@ -0,0 +1,982 @@
# NEXT_STEPS_427.md — Chaos System Final Cutover (2026-04-27)
## Philosophy
We write 1000-5000 LoC/hour. We do NOT do quick hacks or backward compatibility. Every change is a clean cutover. We parallelize via subworkers. We go red-green-refactor with fast feedback loops.
## Status: v2.2 Stabilized → v2.3 Chaos Finalization
**Test count**: 505 passing, 0 failures
**Build**: Clean
**Goal**: Remove all dead code, unify APIs, fix naming lies, wire what exists, document honestly, then extend chaos into contract-driven outbound mocking.
---
## P0: Kill Dead Code (Parallel Batch 1)
### P0.1: Remove `services` field from all config types
- **Files**: `src/types.ts`, `src/quality/chaos-v2.ts`, `src/quality/chaos-types.ts`
- **Action**: Delete `services?: Record<string, ServiceChaosConfig>` from all types
- **Rationale**: Documented fantasy. Zero implementation. Types for unimplemented features are worse than no types.
- **Verification**: `npm run build` passes, tests pass
### P0.2: Remove `DependencyChaosConfig`
- **Files**: `src/quality/chaos-v2.ts`
- **Action**: Delete the interface. It is never exported from the package entry point.
- **Rationale**: Dead code. Duplicates `EnhancedChaosConfig` minus `routes`.
### P0.3: Remove `makeInvalidJson` from `corruption.ts`
- **Files**: `src/quality/corruption.ts`
- **Action**: Delete function. It is defined but never wired into `BUILTIN_STRATEGIES`.
- **Rationale**: Dead code. Also dangerous (swaps body type from object to string silently).
### P0.4: Remove unreachable transport event types
- **Files**: `src/quality/chaos-types.ts`, `src/quality/chaos-v2.ts`
- **Action**: Delete `transport-partial` and `transport-corrupt-headers` from `ChaosInjectionType` union
- **Rationale**: In the type union but no strategy produces them. No implementation. No tests.
- **Alternative**: If we want them, implement them properly in this session. But cut first, add later.
### P0.5: Remove `reportInDiagnostics` flag
- **Files**: `src/quality/chaos-types.ts`, `src/quality/chaos-v2.ts`
- **Action**: Delete field from `EnhancedChaosConfig`. Never checked in engine code.
- **Rationale**: Dead config. Confusing — chaos events are always reported if they occur.
---
## P1: Unify Config Types (Single Source of Truth)
### P1.1: Merge all chaos config into one type
- **Files**: `src/types.ts` (primary), `src/quality/chaos-v2.ts`, `src/quality/chaos-types.ts`
- **Action**:
1. Extend `ChaosConfig` in `src/types.ts` with:
- `outbound?: OutboundChaosConfig[]`
- `include?: string[]`
- `exclude?: string[]`
- `resilience?: { enabled: boolean; maxRetries: number; backoffMs: number }`
- `skipResilienceFor?: ('constructor' | 'mutator' | 'observer' | 'destructor' | 'utility')[]`
- `routes?: Record<string, Partial<ChaosConfig>>` (per-route overrides)
2. Delete `EnhancedChaosConfig` from `chaos-types.ts` and `chaos-v2.ts`
3. Update all imports site-wide
- **Rationale**: Four config types for one concept is insane. One type, one import, one mental model.
- **Breaking**: Yes. Clean cutover. No backward compat.
### P1.2: Fix `corruption.strategies` — either implement or delete
- **Files**: `src/types.ts`, `src/quality/corruption.ts`, `src/quality/chaos-v2.ts`
- **Decision**: DELETE the field. It is documented three different ways and used zero ways.
- **Rationale**: Dead parameter. If we want strategy allow-listing later, we'll design it properly.
---
## P2: Fix Naming Lies (Transport → Body)
### P2.1: Rename transport event types to body-*
- **Files**: `src/quality/chaos-types.ts`, `src/quality/chaos-v2.ts`, `src/quality/corruption.ts`, all tests
- **Action**:
- `transport-truncate``body-truncate`
- `transport-malformed``body-malformed`
- Remove `transport-partial` and `transport-corrupt-headers` (already killed in P0)
- **Rationale**: We manipulate deserialized JS values, not TCP bytes. Stop overpromising.
- **Docs update**: `docs/chaos-v2.md`, `docs/getting-started.md`
### P2.2: Rename `injectCorruption` to `injectBodyCorruption`
- **Files**: `src/quality/chaos-v2.ts`
- **Action**: Method rename. Internal only.
---
## P3: Fix Strategy Mapping (Structural Descriptors)
### P3.1: Replace substring matching with structural descriptors
- **Files**: `src/quality/corruption.ts`, `src/quality/chaos-v2.ts`
- **Current**: `mapCorruptionToTransportType` does `name.includes('truncate')` etc.
- **New**: Each strategy object carries its own `kind`:
```typescript
interface CorruptionStrategy {
readonly name: string
readonly kind: 'body-truncate' | 'body-malformed'
readonly fn: (data: unknown, rng: () => number) => unknown
}
```
- **Rationale**: Substring matching on human-readable names is fragile. Renaming a strategy silently reroutes event types.
---
## P4: Wire Outbound Interceptor (The Big One)
### P4.1: Integrate `OutboundInterceptor` into test runner
- **Files**: `src/test/petit-runner.ts`, `src/quality/chaos-v2.ts`
- **Problem**: `getOutboundInterceptor()` exists but nothing calls it.
- **Solution**:
1. Add a Fastify decorator or request-scoped container that exposes the interceptor
2. OR: Patch `fetch` / `http.request` at test setup time to route through interceptor
3. OR: Provide a helper that wraps the user's HTTP client:
```typescript
const fetchWithChaos = engine.wrapFetch(globalThis.fetch)
```
- **Decision**: Start with option 3 (helper). Fastify-agnostic. Works with any HTTP client.
- **Rationale**: We can't intercept inside handlers without cooperation. Give developers the tool.
### P4.2: Add `wrapFetch` / `wrapHttp` helpers
- **Files**: `src/quality/chaos-outbound.ts` (new exports)
- **API**:
```typescript
export function wrapFetch(
fetch: typeof globalThis.fetch,
interceptor: OutboundInterceptor
): typeof globalThis.fetch
```
- **Rationale**: Makes outbound chaos usable. Currently it's a class with no plumbing.
### P4.3: Wire per-route outbound overrides
- **Files**: `src/quality/chaos-v2.ts`, `src/quality/chaos-route-resolver.ts`
- **Problem**: `getRouteConfig` merges legacy overrides but ignores `resolveOutboundForRoute()`
- **Fix**: Call `resolveOutboundForRoute(config, route)` in `executeWithChaos` and pass result to `OutboundInterceptor`
---
## P5: RNG Forking (Reproducibility)
### P5.1: Fork RNG per chaos layer
- **Files**: `src/quality/chaos-v2.ts`
- **Current**: Both transport and outbound use same `seed` → same RNG stream
- **Fix**:
```typescript
const transportRng = new SeededRng(hashCombine(seed, 'transport'))
const outboundRng = new SeededRng(hashCombine(seed, 'outbound'))
```
- **Rationale**: Adding outbound config currently shifts transport reproducibility. That's a bug.
---
## P6: Blast Radius Cap (Safety)
### P6.1: Add `maxInjectionsPerSuite` circuit breaker
- **Files**: `src/quality/chaos-v2.ts`, `src/types.ts`
- **API**: Add to `ChaosConfig`:
```typescript
readonly maxInjectionsPerSuite?: number // default: Infinity
```
- **Behavior**: Counter in `EnhancedChaosEngine`. Once reached, `executeWithChaos` becomes no-op.
- **Rationale**: Prevents `probability: 1` from masking every assertion in CI.
---
## P7: Fix `truncateJson` RNG
- **Files**: `src/quality/corruption.ts`
- **Problem**: Declares `rng` parameter but ignores it. Cut point is always `floor(n/2)`.
- **Fix**: Either remove param from signature, or use it for random cut point.
- **Decision**: Use it. `const cut = Math.floor(rng() * n)` for arrays, `Math.floor(rng() * str.length)` for strings.
---
## P8: Fix `assertTestEnv` Runtime Violation
- **Files**: `src/quality/chaos-v2.ts`, `src/infrastructure/env-guard.ts`
- **Problem**: `assertTestEnv` called inside `executeWithChaos` at request time. Its own invariant says "MUST only be called at plugin registration time."
- **Fix**: Move the check to plugin registration. Cache result. Pass a boolean `testEnv` flag into `executeWithChaos`.
---
## P9: Documentation
### P9.1: Document transport/body chaos in `getting-started.md`
- **Current**: Zero mention. Only `chaos: { probability, delay }` example.
- **Add**: Section showing `corruption` config with body-truncate, body-malformed examples.
### P9.2: Update `docs/chaos-v2.md`
- **Fix**: Remove references to `strategies` array. Update type names. Remove `services` examples.
- **Add**: `wrapFetch` example for outbound chaos.
### P9.3: Update `docs/extensions/QUICK-REFERENCE.md`
- **Add**: Chaos section with quick examples.
---
## P10: Remaining from 426 (Deferred Items)
### P10.1: Arbiter Bug #3 — Configurable Invariants
- **Status**: Complete
- **Files**: `src/types.ts`, `src/domain/invariant-registry.ts`, `src/test/petit-runner.ts`, `src/test/stateful-runner.ts`
- **Implemented**: `TestConfig.invariants?: string[] | false` with `resolveInvariants()` routing in both runners
### P10.2: CI/CD Examples
- **Status**: Still pending
- **Files**: `docs/ci-cd.md` (new)
- **Need**: GitHub Actions, GitLab CI, CircleCI workflows
- **Defer to**: v2.4 or integrate if time permits
### P10.3: Mutation Testing Cleanup
- **Status**: `src/quality/mutation.ts` exists but is unused
- **Decision**: Keep file. It's not breaking anything. Integrate properly in v2.4.
---
## P11: Contract-Driven Outbound Mocks (Next Major Cut)
### P11.1: Register shared outbound dependency contracts
- **Status**: Complete
- **Files**: `src/types.ts`, `src/plugin/index.ts`, new `src/domain/outbound-contracts.ts`
- **Implemented**: `ApophisOptions.outboundContracts`, `OutboundContractRegistry`, `registerOutboundContracts()` decoration
### P11.2: Add `x-outbound` route annotation
- **Status**: Complete
- **Files**: `src/domain/contract.ts`, `src/types.ts`
- **Implemented**: `RouteContract.outbound`, parsed from `schema['x-outbound']`. Supports string refs, ref-with-overrides, and inline contracts
### P11.3: Add automatic test-env outbound mock runtime
- **Status**: Complete
- **Files**: `src/plugin/index.ts`, new `src/infrastructure/outbound-mock-runtime.ts`, `src/test/petit-runner.ts`, `src/test/stateful-runner.ts`
- **Implemented**: `OutboundMockRuntime` patches `globalThis.fetch`, returns generated/overridden responses, records calls, restores cleanly. Imperative API via `enableOutboundMocks()`, `disableOutboundMocks()`, `getOutboundCalls()`
### P11.4: Reuse existing outbound chaos as a mock overlay
- **Status**: Complete (architectural — chaos-v2 still owns chaos, mock runtime owns dependency mocking; both work alongside via fetch wrapping)
- **Files**: `src/quality/chaos-v2.ts`, `src/quality/chaos-outbound.ts`
- **Migrated**: `stateful-runner.ts` now uses `EnhancedChaosEngine` (single chaos stack across runners)
### P11.5: Expose outbound call facts to APOSTL and E2E tests
- **Status**: Complete
- **Files**: new `src/extensions/outbound.ts`, `src/types.ts`
- **Implemented**: Built-in extension exposing `outbound_calls(this)` and `outbound_last(this)` predicates. Imperative `getOutboundCalls()` API for E2E tests.
### P11.6: Property-test both sides of the integration boundary
- **Status**: Phase 1 complete (`mode: 'example'` works deterministically). Phase 2 (`mode: 'property'`) deferred — types and runtime allow additive change without rewrite.
- **Files**: `src/domain/schema-to-arbitrary.ts`, `src/test/petit-runner.ts`, `src/test/stateful-runner.ts`
- **Implemented**: `convertSchema(responseSchema, { context: 'response' })` reused for dependency response generation. Deterministic sub-seeds derived from test seed via `hashCombine(seed, stringHash(routePath))`.
### P11.7: Tests
- **Status**: Complete
- **File**: `src/test/outbound-runtime.test.ts`
- **Coverage**: Registry resolution (string refs, refs with overrides, inline, missing refs), runtime install/restore, generated responses, overrides, unmatched error/passthrough, call recording, double-install protection. 10/10 tests passing.
### P11.8: Async-to-Sync Conversion
- **Status**: Complete
- **Files**: `src/extensions/serializers/transformer.ts`, `src/extensions/sse/transformer.ts`, `src/extensions/websocket/runner.ts`, `src/plugin/index.ts`
- **Converted**: `transformRequest`, `transformResponse`, `transformSSEResponse`, `runWebSocketTests`, `enableOutboundMocks`, `disableOutboundMocks`
- **Rationale**: Removed unnecessary `async`/`await` overhead on functions that perform no async work. Reduces microtask queue pressure.
---
## P12: Production-Safety Hardening (Reviewer-Driven)
**Context**: Engineering review by simulated personas (Hanson/Halliday/Dahl) identified production-safety concerns. We are NOT stripping APOPHIS down — the framework's scope is correct for the end goal. Instead, we harden every dangerous edge so APOPHIS becomes safe to ship in any environment, while preserving every feature.
**Outcome**: APOPHIS that is fully featured AND impossible to misuse in production.
### P12.1: Replace `globalThis.fetch` Patching with undici MockAgent + AsyncLocalStorage
- **Status**: Pending
- **Files**: `src/infrastructure/outbound-mock-runtime.ts` (rewrite), `src/test/petit-runner.ts`, `src/test/stateful-runner.ts`, `src/plugin/index.ts`
- **Problem**: Current `globalThis.fetch` patching is process-global, not concurrency-safe, bypassed by code that captures `fetch` at module load (Stripe SDK, undici Pool), and uses naive `url.includes(target)` substring matching which is exploitable.
- **Solution**:
1. Replace fetch monkey-patching with undici's `MockAgent` + `setGlobalDispatcher`
2. Wrap mock state in `AsyncLocalStorage<MockContext>` so concurrent test suites don't collide
3. Use `URL` parsing for target matching (hostname + path prefix), not substring
4. Restore previous dispatcher (not just `globalThis.fetch`) on teardown
- **API**:
```typescript
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'
import { AsyncLocalStorage } from 'node:async_hooks'
const mockContext = new AsyncLocalStorage<MockContext>()
export function createOutboundMockRuntime(opts: OutboundMockOptions): OutboundMockRuntime {
const agent = new MockAgent({ connections: 1 })
agent.disableNetConnect()
const previousDispatcher = getGlobalDispatcher()
// ... interceptors set up via agent.get(origin).intercept({path, method}).reply(...)
return {
install: () => mockContext.run({ agent }, () => setGlobalDispatcher(agent)),
restore: () => setGlobalDispatcher(previousDispatcher),
// ...
}
}
```
- **Migration path**: undici is already a Fastify dependency (it ships with Node 18+). Zero new deps.
- **Rationale**: Both Hanson and Dahl identified this as the single biggest production risk. undici MockAgent is the standard, AsyncLocalStorage solves concurrency.
### P12.2: Hard-Fail at Plugin Registration if `NODE_ENV=production` and Unsafe Options Set
- **Status**: Pending
- **Files**: `src/plugin/index.ts`, `src/infrastructure/env-guard.ts`
- **Problem**: Currently `enableOutboundMocks` and chaos can be enabled at runtime in production with no guardrail. `assertTestEnv` only fires when chaos engine is constructed, not at plugin boot.
- **Solution**:
1. Move all environment checks to plugin `onReady` hook
2. Refuse to start the Fastify instance if any unsafe option is set in production:
- `runtime: 'error' | 'warn'` (any non-'off' value)
- `chaos` config present
- `outboundContracts` registered (even via `apophis.registerOutboundContracts`)
3. Throw with explicit error message including the offending option and the env var to override
4. Add escape hatch: `APOPHIS_FORCE_PRODUCTION_DANGEROUS=1` env var for users who genuinely need it
- **Code shape**:
```typescript
fastify.addHook('onReady', async () => {
if (process.env.NODE_ENV === 'production' && !process.env.APOPHIS_FORCE_PRODUCTION_DANGEROUS) {
const violations = []
if (opts.runtime && opts.runtime !== 'off') violations.push('runtime hooks')
if (opts.chaos) violations.push('chaos engine')
if (Object.keys(opts.outboundContracts ?? {}).length > 0) violations.push('outbound mocks')
if (violations.length > 0) {
throw new Error(
`APOPHIS refuses to start in production with: ${violations.join(', ')}. ` +
`Set APOPHIS_FORCE_PRODUCTION_DANGEROUS=1 to override (not recommended).`
)
}
}
})
```
- **Rationale**: `onReady` is the right layer — it's after registration, before serving. Hanson explicitly called this out.
### P12.3: AsyncLocalStorage-Scoped Mock Context (Concurrent Test Safety)
- **Status**: Pending (depends on P12.1)
- **Files**: `src/infrastructure/outbound-mock-runtime.ts`, `src/test/petit-runner.ts`, `src/test/stateful-runner.ts`
- **Problem**: Two test suites running in parallel (`Promise.all([suiteA(), suiteB()])`) silently share `globalThis.fetch` patches.
- **Solution**:
1. All mock state (resources, calls, injected responses) lives in `AsyncLocalStorage<MockContext>`
2. Each `runPetitTests` invocation creates a fresh context via `mockContext.run(...)`
3. The undici dispatcher reads the current ALS context to find the right mock
- **Verification**: Add test that runs two concurrent test suites with different mocks and asserts isolation.
### P12.4: Try/Finally Wrap All Mock Lifecycle (Cleanup-on-Throw)
- **Status**: Pending
- **Files**: `src/test/petit-runner.ts`, `src/test/stateful-runner.ts`
- **Problem**: Current code does `suiteMockRuntime.install()` then later `suiteMockRuntime.restore()`. If any exception fires between them, fetch is leaked.
- **Solution**:
1. Wrap entire test execution in `try { ... } finally { suiteMockRuntime.restore() }`
2. Register restore callback in `CleanupManager` so SIGINT/SIGTERM also restores
3. Add idempotent `restore()` (safe to call twice)
- **Verification**: Test that throws mid-suite and asserts `globalThis.fetch === originalFetch` after.
### P12.5: URL-Aware Target Matching (Replace Substring)
- **Status**: Pending (depends on P12.1)
- **Files**: `src/infrastructure/outbound-mock-runtime.ts`, new `src/domain/url-matcher.ts`
- **Problem**: `url.includes(target)` matches `api.stripe.com.evil.example` to `target: 'api.stripe.com'`.
- **Solution**:
1. Parse target with `new URL()`. Match on `hostname` exactly + `pathname` prefix.
2. Support glob patterns at path-segment boundaries: `/v1/customers/*` matches `/v1/customers/cus_123` but not `/v1/customers_evil/x`
3. Escape regex metacharacters in user-supplied targets
- **Code shape**:
```typescript
export interface UrlMatcher {
readonly hostname: string
readonly pathPattern: RegExp
readonly method: string
}
export function compileTargetPattern(target: string): UrlMatcher
export function matchesUrl(url: string, matcher: UrlMatcher, method: string): boolean
```
### P12.6: Schema-Validate Mock Responses Against Contract
- **Status**: Pending
- **Files**: `src/infrastructure/outbound-mock-runtime.ts`
- **Problem**: After `applyEnsuresToResponse` mutates the body, nothing re-validates against the response schema. A user-written `ensures` formula could produce a response that violates the contract it claims to uphold.
- **Solution**:
1. After applying ensures, run Ajv validation against `contract.response[statusCode]`
2. If validation fails, throw a clear error pointing at the offending formula and the schema violation
3. Cache compiled validators per contract for performance
- **Rationale**: Trust but verify. The mock runtime should be self-consistent.
### P12.7: Fix RNG Determinism (Eliminate `Math.random()` Fallbacks)
- **Status**: Pending
- **Files**: `src/plugin/index.ts:128`, `src/test/petit-runner.ts:539`, `src/infrastructure/outbound-mock-runtime.ts:91`
- **Problem**: `Math.floor(Math.random() * 0xFFFFFFFF)` as a fallback when no seed is provided breaks reproducibility silently.
- **Solution**:
1. When no seed is provided, derive deterministic seed from a stable source (e.g., `stringHash(process.pid + suite-name)` or accept default seed `0`)
2. Replace `seed + N` patterns with `hashCombine(seed, N)` everywhere (consistency with `petit-runner.ts:48`)
3. Document that seeds must be provided for reproducibility OR accept the default seed
- **Rationale**: For a framework whose selling point is reproducibility, `Math.random()` anywhere in the seed chain is a bug.
### P12.8: Discriminated Union for `OutboundBinding` (Tagged, Not Structural)
- **Status**: Pending
- **Files**: `src/types.ts:339-360`, `src/test/petit-runner.ts`, `src/domain/contract.ts`, `src/domain/outbound-contracts.ts`
- **Problem**: Three call sites do `typeof binding === 'string' ? binding : 'ref' in binding ? binding.ref : binding.name` — structural narrowing that's fragile.
- **Solution**:
1. Introduce explicit tag:
```typescript
export type OutboundBinding =
| { kind: 'ref'; name: string; chaos?: OutboundChaosConfig }
| { kind: 'inline'; name: string; target: string; method: string; request?: ...; response: ...; chaos?: ... }
```
2. Backward-compat: `extractContract` normalizes string shorthand to `{ kind: 'ref', name }` at parse time
3. Add helper `getBindingName(binding: OutboundBinding): string` — single source of truth
- **Rationale**: TypeScript discriminated unions with explicit tags are refactor-safe; structural ones aren't.
### P12.9: Eliminate `as unknown as` Mutation of Readonly Types
- **Status**: Pending
- **Files**: `src/test/petit-runner.ts:735-749`, audit all other `as unknown as` casts
- **Problem**: Mutating `readonly TestResult.diagnostics` via double-cast lies to the type system.
- **Solution**:
1. Introduce `MutableTestResult` for in-construction state, freeze to `TestResult` on push
2. OR: use a builder pattern — `TestResultBuilder` accumulates diagnostics, calls `.build()` at the end
3. Run grep for all `as unknown as` and audit each one
- **Verification**: New ESLint rule: forbid `as unknown as Record<string, unknown>` patterns (custom rule).
### P12.10: Hoist Imports in `petit-runner.ts`
- **Status**: Pending
- **Files**: `src/test/petit-runner.ts:264-268`
- **Problem**: Mid-file imports from `dual-boundary-testing.js` are a tell that they were tacked on later.
- **Solution**: Move all imports to top of file. Pure cleanup.
### P12.11: Cache Mock Response Arbitraries (Performance)
- **Status**: Pending
- **Files**: `src/infrastructure/outbound-mock-runtime.ts`
- **Problem**: `fc.sample(arb, ...)` called inside the patched fetch on every outbound call. Builds full schema-to-arbitrary pipeline per sample.
- **Solution**:
1. Pre-compile arbitraries per contract at runtime install time
2. Cache them on the runtime instance: `Map<string, { [statusCode: number]: Arbitrary<unknown> }>`
3. Sample from cache, not rebuild
- **Verification**: Benchmark: 1000 outbound calls before/after. Should be 5-10x faster.
### P12.12: Property-Test Cache Invalidation on Schema Change
- **Status**: Pending
- **Files**: `src/incremental/cache.ts`, `src/test/petit-runner.ts:151-196`
- **Problem**: `generateCommands` caches commands per route. After first run, the property-based aspect is gone unless the schema hash changes — fast-check can't shrink against cached examples.
- **Solution**:
1. Cache should store the *seed and depth*, not the resolved samples
2. Re-sample on every run with cached seed for deterministic re-exploration
3. Only cache the `arbitrary` reference (compiled), not the samples
- **Rationale**: This restores property-based testing semantics. The framework's name says "property-based" — make it true.
### P12.13: Strict `OperationResolver` Production Guard
- **Status**: Pending
- **Files**: `src/formula/runtime.ts`, `src/plugin/index.ts`
- **Problem**: The `previous(GET /users/{id})` operation resolver makes real `fastify.inject()` calls. In `runtime: 'error'` mode in production, this means every request triggers extra inject calls.
- **Solution**:
1. Disable operation resolution entirely when `runtime !== 'off'` and `NODE_ENV === 'production'`
2. Throw at plugin boot with clear error if combination is detected
3. Document: APOSTL `previous()` is for test-time only
### P12.14: Documentation — Production Safety Section
- **Status**: Pending
- **Files**: `docs/PRODUCTION_SAFETY.md` (new), `docs/getting-started.md`
- **Content**:
1. Threat model: what runs in test, what runs in production
2. Required env guards
3. How to disable runtime hooks safely
4. How to verify mocks are not active in production (health check)
5. The `APOPHIS_FORCE_PRODUCTION_DANGEROUS` escape hatch and its risks
### P12.15: Add Test for Production-Mode Refusal
- **Status**: Pending
- **Files**: `src/test/production-guard.test.ts` (new)
- **Coverage**:
- Plugin throws at `ready()` if `NODE_ENV=production` + chaos
- Plugin throws at `ready()` if `NODE_ENV=production` + outbound contracts
- Plugin throws at `ready()` if `NODE_ENV=production` + `runtime: 'error'`
- Plugin allows boot with `APOPHIS_FORCE_PRODUCTION_DANGEROUS=1`
- Concurrent test suites with different mocks don't cross-contaminate (P12.3)
- Mock leak after thrown exception is impossible (P12.4)
---
## P13: Polish from Reviews (Lower Priority, Same Sprint)
### P13.1: ValidatedFormula Real Brand
- **Status**: Pending
- **Files**: `src/types.ts:14`
- **Problem**: `type ValidatedFormula = string` is a lying type alias.
- **Solution**:
```typescript
declare const ValidatedFormulaBrand: unique symbol
export type ValidatedFormula = string & { readonly [ValidatedFormulaBrand]: true }
export function validateFormula(s: string): ValidatedFormula { /* parse-check */ return s as ValidatedFormula }
```
- **Migration**: All formula strings flow through `validateFormula()`. Clear error if invalid.
### P13.2: Re-export `ApophisExtension` Type at Public Boundary
- **Status**: Pending
- **Files**: `src/types.ts:631`, `src/index.ts`
- **Problem**: `extensions?: ReadonlyArray<unknown>` is `unknown` at the public API. The real type lives in `extension/types`.
- **Solution**: Re-export `ApophisExtension` from the public `index.ts` and update the option type.
### P13.3: Header Typing Honesty
- **Status**: Pending
- **Files**: `src/extension/hook-validator.ts:60,75`
- **Problem**: `request.headers as Record<string, string>` loses multi-value headers.
- **Solution**: Use `Record<string, string | string[] | undefined>` and have formula evaluator handle the union.
### P13.4: O(n) Deduplication
- **Status**: Pending
- **Files**: `src/test/petit-runner.ts:813-852`
- **Problem**: O(n²) duplicate count.
- **Solution**: Single-pass `Map<key, count>`, then construct results once.
### P13.5: Single Source for Field-Mapping Regex
- **Status**: Pending
- **Files**: `src/domain/dual-boundary-testing.ts:84`, `src/infrastructure/outbound-mock-runtime.ts:100`
- **Problem**: Same `request_body.X == response_body.Y` regex in two places, slightly different.
- **Solution**: Extract to `src/domain/ensures-templates.ts`. Single regex, both files import.
### P13.6: Multi-Injection Queue for `injectResponse`
- **Status**: Pending
- **Files**: `src/infrastructure/outbound-mock-runtime.ts`
- **Problem**: `injectResponse` is one-shot per contract. Two calls to the same dependency in one test only honor the first injection.
- **Solution**: Change `Map<string, InjectedResponse>` to `Map<string, InjectedResponse[]>` (FIFO queue). Document semantics clearly.
---
## P14: API Surface Simplification — 5 Methods Only
**Context**: Current `ApophisDecorations` has 14 methods (including 3 deprecated). Reviews identified this as cognitive overload. We can achieve the same expressiveness with 5 core methods by moving configuration to options and test-only helpers to a separate namespace.
**Principle**: Jobs to be Done drive the API. Everything else moves to options or test utilities.
### P14.1: Define the 5 Core Methods
| Method | Job to be Done | Current Equivalent |
|--------|----------------|-------------------|
| `contract(opts?)` | Test my routes with generated inputs | `contract()` |
| `stateful(opts?)` | Test stateful workflows across multiple operations | `stateful()` |
| `check(method, path)` | Validate a single route immediately | `check()` |
| `cleanup()` | Clean up resources created during tests | `cleanup()` |
| `spec()` | Export contracts as OpenAPI spec | `spec()` |
**Removed from decorations**:
- `scope` — internal registry, not user-facing
- `registerPluginContracts` — move to `ApophisOptions.extensions`
- `registerOutboundContracts` — move to `ApophisOptions.outboundContracts`
- `enableOutboundMocks`, `disableOutboundMocks`, `getOutboundCalls` — move to `fastify.apophis.test.*` namespace
- `capture`, `extend`, `use` — already deprecated, remove entirely
### P14.2: Move Configuration to Options
**Before**:
```typescript
await fastify.register(apophis, { /* minimal */ })
fastify.apophis.registerOutboundContracts({ stripe: {...} })
fastify.apophis.registerPluginContracts('auth', {...})
```
**After**:
```typescript
await fastify.register(apophis, {
outboundContracts: { stripe: {...} },
extensions: [authExtension],
})
```
**Files**: `src/types.ts`, `src/plugin/index.ts`, `src/index.ts`
### P14.3: Create Test-Only Namespace
Move imperative mock controls to `fastify.apophis.test.*` — clearly indicating these are for test environments only:
```typescript
// Only available when NODE_ENV !== 'production' OR when explicitly enabled
interface ApophisTestNamespace {
// --- Mock lifecycle ---
/** Enable outbound mocking. Idempotent — safe to call multiple times. */
enableOutboundMocks(opts?: TestConfig['outboundMocks']): void
/** Disable outbound mocking. Idempotent. */
disableOutboundMocks(): void
/** Reset all mock state (calls, resources, injections) without disabling. Use between tests. */
resetMocks(): void
// --- Mock inspection ---
/** Get recorded outbound calls. Filter by contract name if provided. */
getOutboundCalls(name?: string): ReadonlyArray<OutboundCallRecord>
/** Get the most recent outbound call to a contract, or undefined if none. */
getLastOutboundCall(name: string): OutboundCallRecord | undefined
/** Get a stored mock resource by contract name and ID. Used to verify CRUD lifecycle. */
getMockResource(contractName: string, id: string): unknown | undefined
// --- Mock control ---
/** Inject a specific response for the next call to a contract. FIFO queue if called multiple times. */
injectResponse(contractName: string, statusCode: number, body: unknown): void
/** Force a specific status code for ALL calls to a contract until cleared. */
forceStatus(contractName: string, statusCode: number): void
/** Clear forced status for a contract. */
clearForceStatus(contractName: string): void
// --- Reproducibility ---
/** Get the seed used by the last test run. Use to reproduce failures. */
getLastSeed(): number | undefined
}
```
**Final E2E test pattern**:
```typescript
import { test, beforeEach, afterEach } from 'node:test'
beforeEach(() => {
fastify.apophis.test.enableOutboundMocks()
})
afterEach(() => {
fastify.apophis.test.resetMocks()
fastify.apophis.test.disableOutboundMocks()
})
test('handles Stripe 500 gracefully', async () => {
fastify.apophis.test.injectResponse('stripe', 500, { error: 'temporary' })
const res = await fastify.inject({ method: 'POST', url: '/charge', payload: {...} })
assert.equal(res.statusCode, 503) // Our handler converts upstream 500 to 503
const calls = fastify.apophis.test.getOutboundCalls('stripe')
assert.equal(calls.length, 1)
assert.equal(calls[0].responseStatus, 500)
})
test('CRUD lifecycle works', async () => {
await fastify.inject({ method: 'POST', url: '/users', payload: { name: 'a' } })
const lastCall = fastify.apophis.test.getLastOutboundCall('user-db')
assert.ok(lastCall)
const stored = fastify.apophis.test.getMockResource('user-db', lastCall.responseBody.id)
assert.equal(stored.name, 'a')
})
test('reproduces failure from CI seed 12345', async () => {
await fastify.apophis.contract({ seed: 12345 })
// If failure happens, getLastSeed() returns 12345 for next run
})
```
**Rationale**:
- Clear separation: core API (5 methods) vs test utilities (10 methods in `test.*`)
- `test.*` namespace signals "not for production" without needing runtime checks
- Can be tree-shaken in production builds
- Each method maps 1:1 to a real E2E job
**Files**: `src/types.ts`, `src/plugin/index.ts`
### P14.4: Update ApophisOptions Interface
Consolidate all configuration into `ApophisOptions`:
```typescript
export interface ApophisOptions {
// Existing
scope?: ScopeConfig
extensions?: ReadonlyArray<ApophisExtension>
// New — moved from imperative decorations
outboundContracts?: Record<string, OutboundContractSpec>
// Existing
invariants?: readonly string[] | false
}
```
**Breaking**: Yes. Clean cutover. Migration guide: move all `register*()` calls to options.
**Files**: `src/types.ts`
### P14.5: Remove Deprecated Decorations
Delete from `ApophisDecorations`:
- `capture` (v1 deprecated)
- `extend` (v1 deprecated)
- `use` (v1 deprecated)
**Files**: `src/types.ts`
### P14.6: Remove `scope` from Decorations
`ScopeRegistry` is an internal concern. Users don't need direct access. If they need scope headers, they pass `scope` to `contract()` or `stateful()`.
**Files**: `src/types.ts`, `src/plugin/index.ts`
### P14.7: Update Plugin Registration to Accept All Config
Modify `apophisPlugin` to:
1. Accept `outboundContracts` in options
2. Register them at boot time (not via decoration)
3. Accept `extensions` array and register all at boot time
**Files**: `src/plugin/index.ts`
### P14.8: Update Documentation
- Update `docs/getting-started.md` with new 5-method API
- Migration guide: "Moving from v2.4 to v2.5"
- Update all examples to use options-based configuration
**Files**: `docs/getting-started.md`, `docs/MIGRATION_v2.5.md` (new)
### P14.9: Add Type Tests for API Surface
Ensure TypeScript enforces the 5-method limit:
```typescript
// src/types/api-surface.test.ts (type tests only)
type ExpectedKeys = 'contract' | 'stateful' | 'check' | 'cleanup' | 'spec' | 'test'
type ActualKeys = keyof ApophisDecorations
type Assert = ActualKeys extends ExpectedKeys ? true : false
const _assert: Assert = true
```
**Files**: `src/types/api-surface.test.ts`
### P14.10: Deprecation Warnings for v2.4 API
For v2.5.0 release, keep old methods but log deprecation warnings pointing to new options-based approach. Remove entirely in v3.0.
Actually — no. Clean cutover per philosophy. Remove in v2.5.
---
## Updated Execution Order
### Batch 7 (Production Safety — HIGHEST PRIORITY)
- P12.1: undici MockAgent
- P12.2: Production refusal at `onReady`
- P12.3: AsyncLocalStorage scoping
- P12.4: try/finally cleanup
- P12.5: URL-aware matching
### Batch 8 (Production Safety — Continuation)
- P12.6: Schema-validate mock responses
- P12.7: RNG determinism fixes
- P12.13: Operation resolver production guard
- P12.14: Production safety docs
- P12.15: Production guard tests
### Batch 9 (API Simplification — PARALLEL with Batch 8)
- P14.1: Define 5 core methods
- P14.2: Move config to options
- P14.3: Create test namespace
- P14.4: Update ApophisOptions
- P14.5: Remove deprecated decorations
- P14.6: Remove scope decoration
- P14.7: Update plugin registration
- P14.8: Update documentation
- P14.9: Add type tests
### Batch 10 (Polish — Parallel)
- P13.*: All review polish items
- P12.8-P12.12: Remaining hardening items
---
## Final API (v2.5 Target)
```typescript
// Registration — all config up front
await fastify.register(apophis, {
outboundContracts: { stripe: {...} },
extensions: [authExtension],
})
// Core API — 5 methods
const suite = await fastify.apophis.contract({ depth: 'standard' })
const suite = await fastify.apophis.stateful({ depth: 'deep' })
const result = await fastify.apophis.check('POST', '/users')
const cleaned = await fastify.apophis.cleanup()
const spec = fastify.apophis.spec()
// Test utilities — separate namespace (10 methods for E2E)
fastify.apophis.test.enableOutboundMocks()
fastify.apophis.test.resetMocks()
fastify.apophis.test.disableOutboundMocks()
const calls = fastify.apophis.test.getOutboundCalls('stripe')
const last = fastify.apophis.test.getLastOutboundCall('stripe')
const resource = fastify.apophis.test.getMockResource('user-db', '123')
fastify.apophis.test.injectResponse('stripe', 500, { error: 'down' })
fastify.apophis.test.forceStatus('stripe', 503)
fastify.apophis.test.clearForceStatus('stripe')
const seed = fastify.apophis.test.getLastSeed()
```
**Total surface**: 5 core + 10 test = **15 methods** (down from 14, but organized).
**Cognitive load**: Low. Core API is 5 methods. Test namespace is comprehensive for E2E. Each maps 1:1 to a Job to be Done.
---
## P15: Triple-Boundary Property Testing (Chaos in Arbitraries)
**Context**: Currently, chaos events are applied as side-effects via `chaosEngine.executeWithChaos()` *inside* the property test. This means fast-check shrinks the request and dependency responses, but chaos events themselves are not part of the shrinking process. If a failure only happens with a specific chaos pattern (e.g., "outbound corruption truncates response after 'id' field"), fast-check cannot find the minimal chaos pattern.
**Solution**: Move chaos generation INTO fast-check arbitraries. Generate request + dependency responses + chaos events together as a single tuple. fast-check then shrinks all three dimensions simultaneously.
**Outcome**: True triple-boundary property testing — when a test fails, the counterexample is minimal across all three boundaries.
### P15.1: Implement Triple-Boundary Arbitrary
- **Status**: Complete (file created)
- **File**: `src/domain/triple-boundary-testing.ts`
- **Implemented**:
- `ChaosEventSample` type (chaos events as data, not side effects)
- `TripleBoundaryCommand` (request + deps + chaos)
- `createTripleBoundaryArbitrary(route, contracts, chaosConfig)` — generates all three together
- `createChaosEventArbitrary` — generates chaos events conditioned on route + contracts
- `applyChaosToDependencyResponse` — applies generated chaos to mock responses (truncate, malformed, field-corrupt)
- `applyChaosToAllResponses` — applies chaos to all dependency responses
- `formatTripleBoundaryCounterexample` — diagnostic output
### P15.2: Add Outbound Response Body Corruption
- **Status**: Complete (in P15.1)
- **Strategies**:
- `truncate` — Remove last field from response body (simulates partial response)
- `malformed` — Replace body with invalid JSON (simulates network/serialization failure)
- `field-corrupt` — Set a specific field to null (simulates bad data from upstream)
- **Rationale**: These are real failure modes from production: partial responses from CDN failures, malformed JSON from broken proxies, null fields from deprecated upstream APIs.
### P15.3: Wire Triple-Boundary into Petit Runner
- **Status**: Pending
- **Files**: `src/test/petit-runner.ts`
- **Changes**:
1. Replace `runDualBoundaryPropertyTest` with `runTripleBoundaryPropertyTest`
2. Pass `chaosConfig` into the new function
3. Inside `fc.asyncProperty`:
- Apply chaos events to dependency responses BEFORE injecting into mock runtime
- Apply inbound chaos events via `chaosEngine.executeWithChaosEvents(events)`
4. Refactor `chaosEngine.executeWithChaos` to accept pre-generated chaos events instead of generating its own
- **API change**:
```typescript
// OLD: chaos generated internally
chaosEngine.executeWithChaos(fn, route, request, extensionRegistry)
// NEW: chaos events passed as data
chaosEngine.applyChaosEvents(fn, chaosEvents, route, request, extensionRegistry)
```
### P15.4: Refactor Chaos Engine to Accept Pre-Generated Events
- **Status**: Pending
- **Files**: `src/quality/chaos-v2.ts`
- **Problem**: `EnhancedChaosEngine.executeWithChaos()` currently rolls its own dice with `Math.random()`. For triple-boundary testing, chaos must be deterministic and shrinkable.
- **Solution**:
1. Add `applyChaosEvents(fn, events, ...)` method that takes pre-generated events
2. Keep `executeWithChaos(fn, ...)` for backward compatibility (single-boundary mode)
3. Internal logic: `executeWithChaos` becomes `applyChaosEvents(fn, generateChaosEvents(rng), ...)`
- **Rationale**: Same engine, two entry points. Property mode uses pre-generated events; example mode rolls dice internally.
### P15.5: Update Mock Runtime to Apply Outbound Corruption
- **Status**: Pending
- **Files**: `src/infrastructure/outbound-mock-runtime.ts`
- **Changes**:
1. Add `injectCorruptedResponse(contractName, statusCode, body, corruption)` method
2. When triple-boundary test runs, it calls `applyChaosToDependencyResponse` then `injectResponse` with the corrupted body
3. The mock returns the corrupted body to the route handler
### P15.6: Add Tests for Triple-Boundary Shrinking
- **Status**: Pending
- **File**: `src/test/triple-boundary.test.ts` (new)
- **Coverage**:
- Triple-boundary arbitrary generates valid commands
- Chaos events shrink toward 'no chaos' when not the cause
- Outbound corruption strategies work (truncate/malformed/field-corrupt)
- Multi-dependency chaos isolates to specific contract
- Counterexample format includes all three boundaries
- Failure boundary detection (request vs dependency vs chaos)
### P15.7: Update Diagnostics
- **Status**: Pending
- **Files**: `src/test/petit-runner.ts`, `src/domain/triple-boundary-testing.ts`
- **Changes**:
- Failure result includes `failureBoundary: 'request' | 'dependency' | 'chaos' | 'combination'`
- Counterexample output shows minimal request, minimal dep responses, minimal chaos events
- Stack trace + APOSTL formula context preserved
### P15.8: Documentation
- **Status**: Pending
- **Files**: `docs/TRIPLE_BOUNDARY_TESTING.md` (new), `docs/getting-started.md`
- **Content**:
- Why triple-boundary > dual-boundary
- Real-world examples: corruption from CDN, partial responses, malformed JSON
- How to read a triple-boundary counterexample
- When to use property mode vs example mode
---
## Updated Execution Order
### Batch 7 (Production Safety — HIGHEST PRIORITY)
- P12.1: undici MockAgent
- P12.2: Production refusal at `onReady`
- P12.3: AsyncLocalStorage scoping
- P12.4: try/finally cleanup
- P12.5: URL-aware matching
### Batch 8 (Production Safety — Continuation)
- P12.6: Schema-validate mock responses
- P12.7: RNG determinism fixes
- P12.13: Operation resolver production guard
- P12.14: Production safety docs
- P12.15: Production guard tests
### Batch 9 (Polish — Parallel with Batch 8)
- P12.8: Discriminated union for OutboundBinding
- P12.9: Remove `as unknown as` casts
- P12.10: Hoist imports
- P12.11: Cache mock arbitraries
- P12.12: Cache invalidation for property tests
- P13.*: All review polish items
---
## Updated Metrics
| Metric | v2.4 | v2.5 Target |
|--------|------|-------------|
| Tests passing | 522 | 540+ |
| `globalThis.*` mutations | 1 | 0 |
| Production-unsafe boot paths | 3 | 0 |
| Concurrent suite safety | No | Yes |
| Mock leak on throw | Possible | Impossible |
| `Math.random()` in seeded paths | 3 | 0 |
| Schema-validated mock responses | No | Yes |
| Structural type narrowing sites | 3+ | 0 |
| undici-based outbound mocking | No | Yes |
| Production safety docs | None | Complete |
---
## Execution Order (Parallel Batches)
### Batch 1 (Independent, Parallel)
- P0: Kill dead code
- P2: Rename transport → body
- P7: Fix truncateJson RNG
- P8: Fix assertTestEnv
### Batch 2 (Depends on Batch 1)
- P1: Unify config types
- P3: Fix strategy mapping
### Batch 3 (Depends on Batch 2)
- P4: Wire outbound interceptor
- P5: RNG forking
- P6: Blast radius cap
### Batch 4 (Documentation, always parallel)
- P9: All docs updates
### Batch 5 (Deferred)
- P10: Bug #3, CI/CD, mutation testing
### Batch 6 (Next Major Cut)
- P11: Contract-driven outbound mocks and dual-boundary property testing
---
## Metrics
| Metric | v2.2 | v2.3 Target |
|--------|------|-------------|
| Tests passing | 505 | 505+ |
| Config types | 4 | 1 |
| Dead code files | 3+ | 0 |
| Unreachable event types | 2 | 0 |
| Outbound chaos wired | No | Yes |
| Transport naming honest | No | Yes |
| Docs cover chaos | Partial | Complete |
---
## Reference
- **Previous Steps**: `NEXT_STEPS_426.md`
- **Arbiter Feedback**: `FEEDBACK_FROM_ARBITER.md`
- **Chaos Spec**: `docs/chaos-v2.md`
- **Outbound Mocking Spec**: `docs/OUTBOUND_CONTRACT_MOCKING_SPEC.md`
- **Plugin Contracts**: `docs/PLUGIN_CONTRACTS_SPEC.md`