2026-03-10 00:00:00 -07:00
# APOPHIS API Redesign — Unified Interface Document
## Rationale
Five independent interface reviews (Substack/minimalist, Jared Hanson/DX, WebReflections/performance, XP theorist, FRP/DDD theorist) were conducted. All five agreed on the core value proposition (schemas as contracts) but identified a shared set of problems: overgrown surface area, leaky abstractions, silent failures, and an over-engineered formula language. This document unifies their feedback into a single coherent redesign.
## Guiding Principles
1. **Split what is separate ** : Runtime validation and test generation are different concerns. Do not force them into one plugin.
2. **Do not export internals ** : The public API should fit on a postcard.
3. **Fail loud ** : A silent empty result is worse than a thrown error.
4. **One way to do things ** : No duplicate syntaxes, no overlapping annotations.
5. **Types are documentation ** : Every public type should prevent misuse at compile time.
---
## The New Public API
### Package Entry Point
``` typescript
2026-05-21 20:39:36 -07:00
import apophis from '@apophis/fastify'
2026-03-10 00:00:00 -07:00
```
The package exports one default: the Fastify plugin. No `export * from './types'` .
### Plugin Registration
``` typescript
await fastify . register ( apophis , {
runtime : 'warn' , // 'off' | 'warn' | 'error' — default: 'off'
cleanup : false , // auto-cleanup on SIGINT/SIGTERM — default: false
} )
```
- **`runtime` **: How to enforce contracts at runtime. `'off'` disables hooks. `'warn'` logs violations without failing the request. `'error'` throws (500). Default is `'off'` because runtime validation is a development aid, not a production default.
- **`cleanup` **: Whether to register process signal handlers. Default `false` because serverless and CLI tools should not have their signals hijacked.
### Test Execution
``` typescript
// Contract tests (fast, deterministic)
const contract = await fastify . apophis . contract ( {
depth : 'quick' , // 'quick' | 'standard' | 'thorough' | { runs: 75 }
scope : 'admin' , // optional scope filter
seed : 12345 , // optional reproducibility seed
} )
// Stateful tests (slower, property-based with fast-check)
const stateful = await fastify . apophis . stateful ( {
depth : 'standard' ,
scope : 'admin' ,
seed : 12345 ,
} )
// Both (if you really want)
const [ contract , stateful ] = await Promise . all ( [
fastify . apophis . contract ( { depth : 'quick' } ) ,
fastify . apophis . stateful ( { depth : 'standard' } ) ,
] )
```
- **`contract()` **: Validates postconditions against generated requests. Does not mutate state. Safe to run against production.
- **`stateful()` **: Generates command sequences that create, mutate, and delete resources. Requires cleanup. Not safe for production databases.
- No `mode: 'all'` merging. No `mergeTestSuites` . The user composes explicitly.
### Per-Route Validation (New)
``` typescript
// Validate a single route in <100ms
const result = await fastify . apophis . check ( 'POST' , '/users' )
// => { ok: boolean, violations: ContractViolation[] }
```
### Spec Extraction
``` typescript
const spec = fastify . apophis . spec ( )
// => OpenAPISpec & { 'x-apophis-contracts': ContractSummary[] }
```
### Cleanup
``` typescript
// Manual cleanup (always available)
const results = await fastify . apophis . cleanup ( )
// => Array<{ resource: TrackedResource; deleted: boolean; error?: string }>
```
### Scope Configuration
``` typescript
// Scopes are passed at plugin registration, not auto-discovered from env
await fastify . register ( apophis , {
scopes : {
prod : {
headers : { 'x-api-key' : 'secret' } ,
metadata : { tenantId : 'prod-tenant' }
}
}
} )
// Access headers for a scope
const headers = fastify . apophis . scope ( 'prod' )
// => Record<string, string>
```
No `ScopeRegistry` class exposed. No `deriveFromRequest` . No env var auto-discovery. Scopes are configuration, not global state.
---
## Schema Annotations
### Required (Core Value)
| Annotation | Type | Description |
|-----------|------|-------------|
| `x-category` | `'constructor' \| 'mutator' \| 'observer' \| 'destructor' \| 'utility'` | Route classification |
| `x-requires` | `RequiresClause[]` | Preconditions |
| `x-ensures` | `EnsuresClause[]` | Postconditions |
### Removed
| Annotation | Reason |
|-----------|--------|
| `x-invariants` | Move to plugin-level option: `invariants: ['response_body(this).id != null']` |
| `x-regex` | JSON Schema `pattern` already exists. No duplication. |
| `x-validate-runtime` | Replaced by plugin-level `runtime` option |
### Scope Filtering
``` typescript
fastify . get ( '/admin' , {
schema : {
'x-scope' : 'admin' , // Still valid: restricts route to admin scope tests
'x-category' : 'observer' ,
'x-ensures' : [ 'status:200' ] ,
}
} )
```
---
## APOSTL Formula Language
APOSTL remains the full-featured contract language. All features are preserved for complex protocol contracts (OAuth 2.1, etc.):
```
// Comparisons
response_body(this).id != null
response_body(this).email == request_body(this).email
response_code(this) == 201
request_headers(this).authorization != null
response_body(this).items matches "^test"
// Boolean combinations
status:200 && response_body(this).id != null
status:200 || status:201
// Conditionals
if response_code(this) == 200 then response_body(this).id != null else true
// Quantified expressions
for item in response_body(this).items: item.status == "active"
exists item in response_body(this).items: item.id != null
// Temporal references
previous(response_body(this).id) != null
// Implication
status:200 => response_body(this).id != null
// Literals
true, false, null, 42, "string", T, F
```
### New: `status:` Is Real APOSTL
```
// Parser now understands this natively
status:201
```
Adds `type: 'status'` to `FormulaNode` . No more special-case string prefix check in contract validation.
---
## Types (Curated Public API)
``` typescript
// Only these types are exported
export interface ApophisOptions {
readonly runtime ? : 'off' | 'warn' | 'error'
readonly cleanup? : boolean
readonly scopes? : Record < string , ScopeConfig >
readonly invariants? : string [ ]
}
export interface ScopeConfig {
readonly headers : Record < string , string >
readonly metadata? : Record < string , unknown >
}
export interface TestConfig {
readonly depth ? : 'quick' | 'standard' | 'thorough' | { runs : number }
readonly scope? : string
readonly seed? : number
}
export interface TestSuite {
readonly tests : TestResult [ ]
readonly summary : TestSummary
readonly routes : RouteDisposition [ ] // NEW: every route discovered and its status
}
export interface TestResult {
readonly ok : boolean
readonly name : string
readonly id : number
readonly directive? : string
readonly diagnostics? : TestDiagnostics
}
export interface TestSummary {
readonly passed : number
readonly failed : number
readonly skipped : number
readonly timeMs : number
}
export interface RouteDisposition {
readonly path : string
readonly method : string
readonly status : 'tested' | 'skipped' | 'no-contract' | 'scope-filtered'
readonly reason? : string
}
export interface ContractViolation {
readonly type : 'contract-violation'
readonly kind : 'precondition' | 'postcondition' | 'invariant' | 'regex'
readonly route : { readonly method : string ; readonly path : string }
readonly formula : string
readonly request : {
readonly body : unknown
readonly headers : Record < string , string >
readonly query : Record < string , unknown >
readonly params : Record < string , unknown >
}
readonly response : {
readonly statusCode : number
readonly headers : Record < string , string >
readonly body : unknown
}
readonly context : {
readonly expected : string
readonly actual : string
readonly diff? : string | null
}
readonly suggestion : string
}
export interface CheckResult {
readonly ok : boolean
readonly violations : ContractViolation [ ]
}
// Internal types are NOT exported:
// FormulaNode, EvalContext, ModelState, ApiCommand, CacheEntry, etc.
```
---
## Error Handling
### Loud Failures (No Silent Empty Results)
``` typescript
// If no routes are discovered, THROW
const result = await fastify . apophis . contract ( )
// => throws: No routes discovered. Did you register APOPHIS before defining routes?
// If scope filter excludes all routes, THROW
await fastify . apophis . contract ( { scope : 'nonexistent' } )
// => throws: Scope 'nonexistent' not found. Available scopes: ['admin', 'user']
// If formula parse fails, THROW with route context
// => ParseError: POST /users, x-ensures[1]: "response_body(this).id != nul"
// Parse error at position 28: Expected identifier
// response_body(this).id != nul
// ^
```
### Diagnostics in TestSuite
``` typescript
const result = await fastify . apophis . contract ( )
// Every route is accounted for
for ( const route of result . routes ) {
console . log ( ` ${ route . method } ${ route . path } : ${ route . status } ` )
// GET /health: tested
// POST /users: tested
// GET /admin: scope-filtered (scope: 'admin' not in test config)
// DELETE /items/:id: no-contract (no x-ensures or x-requires)
}
```
---
## Migration from v0.x to v1.0
### Plugin Registration
``` typescript
// Before
await fastify . register ( apophis , { validateRuntime : true } )
// After
await fastify . register ( apophis , { runtime : 'error' } )
```
### Test Execution
``` typescript
// Before
await fastify . apophis . test ( { mode : 'all' , depth : 'quick' } )
// After
const contract = await fastify . apophis . contract ( { depth : 'quick' } )
const stateful = await fastify . apophis . stateful ( { depth : 'quick' } )
```
### Scope Configuration
``` typescript
// Before (env vars)
// APOPHIS_SCOPE_PROD='{"headers":{"x-api-key":"secret"}}'
await fastify . register ( apophis )
fastify . apophis . scope . getHeaders ( 'prod' )
// After (explicit config)
await fastify . register ( apophis , {
scopes : {
prod : { headers : { 'x-api-key' : 'secret' } }
}
} )
fastify . apophis . scope ( 'prod' )
```
### Removed Annotations
``` typescript
// Before
schema : {
'x-invariants' : [ 'response_body(this).id != null' ] ,
'x-regex' : { email : '^[^@]+@[^@]+$' } ,
'x-validate-runtime' : false ,
}
// After
schema : {
// x-invariants moved to plugin option
// x-regex replaced by JSON Schema pattern
// x-validate-runtime replaced by plugin runtime option
}
```
### Formula Language
``` typescript
// Before (still works)
'if response_code(this) == 200 then response_body(this).id != null else T'
'for item in response_body(this): item.status == "active"'
'previous(response_body(this).id) != null'
// After (removed)
// Use boolean operators instead
'response_code(this) == 200 && response_body(this).id != null'
// Use array element access (if supported in evaluator)
'response_body(this).items.0.status == "active"'
// Temporal contracts removed until bounded
```
---
## Success Metrics
| Metric | Target | How Verified |
|--------|--------|-------------|
| New user: npm install → passing test | < 5 minutes | examples.test.ts |
| Error messages include request/response context | 100% | success-metrics.test.ts |
| Suggestions for violations | 100% | success-metrics.test.ts |
| Silent empty results | 0% | All test calls throw on empty discovery |
| Public API surface | < 10 exported types | types.ts audit |
| Formula parse errors with position | 100% | formula.test.ts |
| Per-route validation latency | < 100ms | benchmark.test.ts |
---
## Remaining Work
### Phase 1: API Surface (Week 1)
- [ ] Split `test()` into `contract()` and `stateful()` methods
- [ ] Remove `mode` and `mergeTestSuites`
- [ ] Add `check(method, path)` per-route validation
- [ ] Add `routes` disposition metadata to `TestSuite`
- [ ] Make empty discovery throw with diagnostic message
- [ ] Curate exports: remove `FormulaNode` , `EvalContext` , `ModelState` , `ApiCommand` , `CacheEntry` , `FastifyInjectInstance` , `ResourceHierarchy` from public API
- [ ] Remove `export * from './types'` from `index.ts`
### Phase 2: Plugin Options (Week 1)
- [ ] Rename `validateRuntime` → `runtime: 'off' | 'warn' | 'error'`
- [ ] Change default from `true` to `'off'`
- [ ] Add `cleanup: boolean` option (default `false` )
- [ ] Move scope config from env discovery to plugin option `scopes`
- [ ] Add `invariants: string[]` plugin option (replacing per-route `x-invariants` )
- [ ] Remove `x-validate-runtime` schema annotation
### Phase 3: APOSTL Simplification (Week 2)
- [ ] Add `type: 'status'` to `FormulaNode` AST (make `status:201` real)
- [ ] Remove `if/then/else` from parser
- [ ] Remove `for` /`exists` quantifiers from parser
- [ ] Remove `previous()` from parser
- [ ] Remove `=>` implication from parser
- [ ] Remove `T` /`F` shorthand from parser
- [ ] Update all tests to use simplified syntax
- [ ] Update documentation
### Phase 4: Schema Annotations (Week 2)
- [ ] Remove `x-invariants` support (migrated to plugin option)
- [ ] Remove `x-regex` support (use JSON Schema `pattern` )
- [ ] Add `destructor` to `OperationCategory` type (or remove from docs)
- [ ] Document annotation precedence rules
### Phase 5: Error Handling (Week 2)
- [ ] Parse errors include route path, method, annotation index
- [ ] Scope mismatch throws with available scopes list
- [ ] `check()` returns `CheckResult` with violations array
- [ ] All test calls fail loudly on empty discovery
### Phase 6: Types (Week 3)
- [ ] Type `spec()` return as `ApophisSpec extends OpenAPI.Document`
- [ ] Make `cacheHits` /`cacheMisses` required (or move to sub-object)
- [ ] Use `seed?: number` instead of `seed: number | undefined`
- [ ] Brand validated types: `ValidatedFormula` , `HttpMethod`
- [ ] Fix `ContractViolation.formulaType` to distinguish pre/post/invariant/regex
- [ ] Add `ContractViolation.kind` field
### Phase 7: Performance (Week 3)
- [ ] Eager-import test runners (remove lazy imports)
- [ ] Static export for `spec()` extraction
- [ ] Cache parsed formulas at route registration time
- [ ] Remove `mergeTestSuites` reindexing overhead
### Phase 8: Documentation (Week 4)
- [ ] Rewrite getting-started.md with new API
- [ ] Document simplified APOSTL grammar
- [ ] Update all examples
- [ ] Migration guide from v0.x
- [ ] API reference (typedoc)
---
## Principles Checklist
- [x] Runtime validation and test generation are separate concerns
- [x] Public API fits on a postcard (< 10 exported types)
- [x] Silent empty results are eliminated (throw instead)
- [x] One way to do things (no duplicate syntaxes)
- [x] Types prevent misuse at compile time
- [x] Signal handlers are opt-in
- [x] Scope configuration is explicit, not magic
- [x] Formula language is simplified to core use cases
- [x] Every test call accounts for every route
- [x] Error messages include full context (route, formula, position)