Files
apophis-fastify/docs/PLUGIN_CONTRACTS_SPEC.md
T
John Dvorak dc7a4205ec fix: correct documented vs actual behavior discrepancies from subworker audit
- Fix verify.md --changed exit code (0 → 2)
- Add 'deep' as alias for 'thorough' in generation profile resolution
- Fix PLUGIN_CONTRACTS_SPEC status: Partially implemented (registry done, runner pending)
- Fix OUTBOUND_CONTRACT_MOCKING_SPEC status: Implemented (Phase 1)
- Fix cli.md environment matrix to match actual code granularity
- Fix chaos.md: document delay handler is currently a no-op
- Fix getting-started.md warning: note APOPHIS does not proactively detect nondeterminism
- Add variants section to getting-started.md
- Build: clean | Tests: 849 pass, 0 fail
2026-04-30 11:50:39 -07:00

14 KiB

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:

fastify.apophis.registerPluginContracts(name: string, spec: PluginContractSpec)

File: src/plugin/index.ts (NEW METHOD, line 130+)

3.2 Plugin Contract Specification

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:

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-requiresroute phase (handler execution)
  • Route-level x-ensuresroute 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:

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)

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)

// 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)

// 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:

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:

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

// 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

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

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.tsPluginContractRegistry 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


Document Version: 1.0 Author: APOPHIS Architecture Team Date: 2026-04-25