# 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 import apophis from 'apophis-fastify' ``` 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 ``` 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 readonly invariants?: string[] } export interface ScopeConfig { readonly headers: Record readonly metadata?: Record } 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 readonly query: Record readonly params: Record } readonly response: { readonly statusCode: number readonly headers: Record 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)