# APOPHIS v2.x — Extension Plugin System Specification ## 1. Overview APOPHIS supports a **first-class extension plugin system** that enables developers to: 1. **Define custom APOSTL predicates** — Graph traversal, partial graph checks, domain-specific assertions 2. **Hook into request building** — Inject headers, certificates, tokens, or modify request structure 3. **Hook into execution lifecycle** — Preflight checks, budget validation, finalize/rollback 4. **Hook into test suite lifecycle** — Setup, teardown, state management 5. **Maintain isolated state** — Per-extension state that persists across the test run This replaces the previous annotation-based approach (`x-auth`, `x-scopes`) with a programmatic API that has explicit lifecycle hooks and per-extension state. --- ## 2. Why Extensions? **Problem**: Arbiter's authorization system is fundamentally incompatible with flat scope arrays: - Arbiter uses **graph-based authorization** with relation traversal - Supports **partial graphs** merged from JWT tokens - Has a **7-layer gate order**: transport → scope/boundary → authz → challenge → resource preflight → execute → finalize - Auth is declared via `preHandler` composition, not schema annotations **Solution**: Instead of baking Arbiter-specific code into APOPHIS core, provide a **generic extension API** that Arbiter (and any other system) can use to express its auth model naturally. --- ## 3. Extension API ### 3.1 Extension Interface ```typescript interface ApophisExtension { /** Unique extension name (used for logging and state isolation) */ readonly name: string /** APOSTL operation headers this extension adds */ readonly headers?: readonly string[] /** Custom APOSTL predicates */ readonly predicates?: Record /** Hook: Modify request before execution */ readonly onBuildRequest?: (context: RequestBuildContext) => RequestStructure | Promise | undefined /** Hook: Called before each request execution */ readonly onBeforeRequest?: (context: ExecutionContext) => Promise /** Hook: Called after each request execution */ readonly onAfterRequest?: (context: ExecutionContext) => Promise /** Hook: Initialize extension state before test suite runs */ readonly onSuiteStart?: (config: TestConfig) => Promise | undefined> | Record | undefined /** Hook: Cleanup after test suite completes */ readonly onSuiteEnd?: (suite: TestSuite, extensionState: Record) => Promise /** Hook: Called when a contract violation is detected */ readonly onViolation?: (violation: ContractViolation, extensionState: Record) => Promise } ``` ### 3.2 Predicate Resolver ```typescript interface PredicateContext { readonly route: RouteContract readonly evalContext: EvalContext readonly accessor: string[] readonly extensionState: Record } interface PredicateResult { readonly value: unknown readonly success: boolean readonly error?: string } type PredicateResolver = (context: PredicateContext) => PredicateResult | Promise ``` --- ## 4. Example: Arbiter Extension ```typescript import type { ApophisExtension, PredicateContext } from 'apophis-fastify' import { createArbiter } from 'arbiter-sdk' const arbiterExtension: ApophisExtension = { name: 'arbiter', // Initialize Arbiter SDK and load configuration onSuiteStart: async (config) => { const arbiter = createArbiter({ apiKey: process.env.ARBITER_API_KEY, tenantId: process.env.ARBITER_TENANT_ID, applicationId: process.env.ARBITER_APPLICATION_ID, }) const graphStore = await arbiter.client.getGraphStore('tenantExternal') return { arbiter, graphStore, tenantId: process.env.ARBITER_TENANT_ID, applicationId: process.env.ARBITER_APPLICATION_ID, } }, // Inject S2S headers into every request onBuildRequest: (ctx) => { const state = ctx.extensionState as { tenantId: string applicationId: string arbiter: ReturnType } return { ...ctx.request, headers: { ...ctx.request.headers, 'x-tenant-id': state.tenantId, 'x-application-id': state.applicationId, ...(ctx.request.headers['authorization'] ? { 'x-s2s-token': ctx.request.headers['authorization'] } : {}), }, } }, // Define graph-based authorization predicates predicates: { // APOSTL: graph_check(this).user.can_manage_system graph_check: (ctx: PredicateContext) => { const state = ctx.extensionState as { graphStore: any } const userKey = ctx.evalContext.request.headers['x-user-key'] const relation = ctx.accessor[0] // e.g., 'can_manage_system' const objectKey = ctx.accessor[1] || 'resource:default' if (!state.graphStore || !relation) { return { value: false, success: true } } const result = state.graphStore.check( String(userKey), relation, objectKey, { partialGraph: ctx.evalContext.request.headers['x-partial-graph'] ? JSON.parse(ctx.evalContext.request.headers['x-partial-graph']) : undefined, } ) return { value: result.allowed === true || result.possibility === 1, success: true, } }, // APOSTL: partial_graph(this).tenant.accessible partial_graph: (ctx: PredicateContext) => { const partialGraph = ctx.extensionState.partialGraph as Record | undefined const path = ctx.accessor.join('.') let current: unknown = partialGraph for (const part of path.split('.')) { if (current && typeof current === 'object') { current = (current as Record)[part] } else { current = undefined break } } return { value: current, success: true } }, // APOSTL: budget_check(this).operation.credits >= 100 budget_check: async (ctx: PredicateContext) => { const state = ctx.extensionState as { arbiter: ReturnType } const operation = ctx.accessor[0] const estimatedCost = Number(ctx.accessor[1]) || 1 const budget = await state.arbiter.budget(`op_${operation}`, { lowerBound: estimatedCost, upperBound: Math.ceil(estimatedCost * 1.2), }) return { value: budget.allowed, success: true, } }, }, // Simulate preflight checks onBeforeRequest: async (ctx) => { const state = ctx.extensionState as { arbiter: ReturnType } // Create preflight record for metered operations if (ctx.route.category === 'constructor' || ctx.route.category === 'mutator') { const preflight = await state.arbiter.preflight({ authorize: { expression: `can_manage_tenant_accounts(:user)`, }, budget: { ref: `op_${ctx.route.method}_${ctx.route.path}`, estimates: { lowerBound: 1, upperBound: 10 }, }, }) // Store preflight ID in extension state for finalize/rollback state.preflightId = preflight.preflightId } }, // Simulate finalize/rollback onAfterRequest: async (ctx) => { const state = ctx.extensionState as { arbiter: ReturnType preflightId?: string } if (state.preflightId) { if (ctx.evalContext.response.statusCode < 400) { // Success: finalize await state.arbiter.finalize({ preflight_id: state.preflightId, summary: { operation: `${ctx.route.method} ${ctx.route.path}`, statusCode: ctx.evalContext.response.statusCode, }, }) } else { // Failure: rollback await state.arbiter.rollback({ preflight_id: state.preflightId, cause: `HTTP ${ctx.evalContext.response.statusCode}`, }) } delete state.preflightId } }, // Cleanup on suite end onSuiteEnd: async (suite, state) => { console.log(`Arbiter extension: ${suite.summary.passed} passed, ${suite.summary.failed} failed`) }, } ``` --- ## 5. Registration ```typescript import fastify from 'fastify' import apophis from 'apophis-fastify' import { arbiterExtension } from './arbiter-extension.js' const app = fastify() await app.register(apophis, { extensions: [arbiterExtension], }) // Routes are defined normally (no schema annotations for auth) app.get('/users/:id', { schema: { response: { 200: { type: 'object', properties: { id: { type: 'string' } }, 'x-ensures': [ // Behavioral: returned user must match the requested id 'response_body(this).id == request_params(this).id', 'graph_check(this).user.can_read_user == true', 'partial_graph(this).tenant.accessible == true', ], }, }, }, }, async (req, reply) => { // Auth is handled by Arbiter preHandlers (not shown) return { id: req.params.id } }) // Run tests with Arbiter extension active const suite = await app.apophis.contract({ depth: 'standard' }) ``` --- ## 6. Extension Lifecycle ``` onSuiteStart(config) → [for each test command] → onBuildRequest(ctx) → onBeforeRequest(ctx) → [execute HTTP request] → onAfterRequest(ctx) → [validate postconditions with extension predicates] → onSuiteEnd(suite) ``` **State Management**: - Each extension has isolated state keyed by `extension.name` - State is set by `onSuiteStart` return value - State is accessible in all hooks via `ctx.extensionState` - State persists across the entire test suite --- ## 7. Predicate Resolution When evaluating APOSTL expressions, the evaluator checks extension predicates **before** standard operations: ``` Expression: graph_check(this).user.can_manage_system 1. Parse: { type: 'operation', header: 'graph_check', accessor: ['user', 'can_manage_system'] } 2. Check extension predicates: 'graph_check' found in arbiter extension 3. Call resolver({ route, evalContext, accessor: ['user', 'can_manage_system'], extensionState }) 4. Return resolver result ``` **Important**: Extensions must not override core operation names unless an explicit override policy is enabled. --- ## 8. Composability Multiple extensions can be registered and their hooks are called in order: ```typescript await app.register(apophis, { extensions: [ loggingExtension, // Logs all requests arbiterExtension, // Auth + accounting metricsExtension, // Collects timing metrics ], }) ``` **Hook calling semantics**: - `onBuildRequest`: Sequential, each extension can modify the request - `onBeforeRequest` / `onAfterRequest`: Sequential in registration order when hooks can mutate extension state; parallel only for hooks declared side-effect-free - `onSuiteStart`: Sequential, state is set per-extension - `onSuiteEnd`: Parallel --- ## 9. Error Handling **Hook failure handling follows extension severity**: - `fatal` failures block execution - `warn` failures record diagnostics and continue - `onBuildRequest` failures propagate because they prevent request construction - Predicate resolver failures throw and are caught by the formula evaluator **Best practices**: - Validate inputs in predicates and return `{ value: false, success: true }` for graceful failure - Use `try/catch` in async hooks to prevent unhandled rejections - Log extension errors with the extension name for debugging --- ## 10. Backward Compatibility - Extensions are **opt-in** — existing APOPHIS v2.x code works unchanged - No schema annotations required for extensions - Standard APOSTL expressions work without any extensions registered - The `evaluate()` function still works for expressions without extension predicates --- ## 11. File Paths | File | Purpose | |------|---------| | `src/extension/types.ts` | Extension interfaces and context types | | `src/extension/registry.ts` | ExtensionRegistry implementation | | `src/test/extension.test.ts` | Extension system tests | | `src/formula/evaluator.ts` | APOSTL evaluator with extension predicate resolution | | `src/domain/contract-validation.ts` | Passes extension registry to evaluator | | `src/test/petit-runner.ts` | Calls extension hooks | | `src/plugin/index.ts` | Creates and passes ExtensionRegistry | --- *End of Extension Plugin System Specification*