- Fix const inference bug: wrap inferred contracts with status-code guards - Add integration test for status-guarded contract inference - Tighten and deduplicate docs across verify, qualify, getting-started, cli - Fix broken cross-references and TypeScript→JavaScript conversions - Fix factual errors: license, Date.now(), sampling defaults, cache env - Add missing features: --workspace, --generation-profile, json-summary formats - Move stale extension docs (AUTH-RATE-LIMIT-REVISED, HTTP-EXTENSIONS) to attic - Update PLUGIN_CONTRACTS_SPEC status to Implemented - Build: clean | Tests: 849 pass, 0 fail
14 KiB
APOPHIS Plugin Contract System Specification
Status: Implemented
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-requires→routephase (handler execution) - Route-level
x-ensures→routephase (handler execution) - Plugin
hooks[phase].requires→ respective phase - Plugin
hooks[phase].ensures→ respective phase - Phase
onRequestcontracts run before route handler - Phase
onSendcontracts run after route handler but before response sent - Phase
onResponsecontracts 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-pluginsis present, APOPHIS MUST warn if any listed plugin has no registered contracts. - If
x-pluginsis present, APOPHIS MUST warn if a plugin'sappliesTodoesn't match this route. - If
x-pluginsis absent, APOPHIS MUST still apply all matching plugin contracts silently. x-pluginsis 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+— AddPluginContractSpec,ComposedContract, extendContractViolationsrc/domain/plugin-contracts.ts—PluginContractRegistryclasssrc/plugin/index.ts:130+— AddregisterPluginContracts()methodsrc/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+— Extractx-pluginsfrom schemasrc/test/petit-runner.ts:180-190— Compose contracts before test generationsrc/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 contractsdocs/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— ExtendTestSummarywith plugin metricssrc/domain/contract-validation.ts— Add source attribution to violationssrc/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-pluginsannotation.x-pluginsis 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-pluginsstill 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
TestSuiteandTestResulttypes MUST remain compatible. New fields are optional.
8. Open Questions
-
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.
-
Plugin versioning: Should contracts include version constraints? e.g.,
@fastify/auth@^4.0.0contracts. -
Conditional contracts: Should plugins declare contracts conditionally based on configuration? e.g., auth plugin with
optional: truemode. -
Performance: Composing contracts for 10K routes with 20 plugins. O(n*m) is acceptable but should be cached.
9. References
Codebase Citations
- Route discovery:
src/domain/discovery.ts:26-32 - Hook validator:
src/infrastructure/hook-validator.ts - Contract extraction:
src/domain/contract.ts:45+ - PETIT runner:
src/test/petit-runner.ts:166-428 - Plugin entry:
src/plugin/index.ts:48-69 - Types:
src/types.ts:170-292
External References
- Fastify Hooks: https://www.fastify.io/docs/latest/Reference/Hooks/
- Fastify Plugin: https://github.com/fastify/fastify-plugin
- Fastify Encapsulation: https://www.fastify.io/docs/latest/Reference/Encapsulation/
Document Version: 1.0 Author: APOPHIS Architecture Team Date: 2026-04-25