2026-03-10 00:00:00 -07:00
# 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 < string , PredicateResolver >
/** Hook: Modify request before execution */
readonly onBuildRequest ? : ( context : RequestBuildContext ) = >
RequestStructure | Promise < RequestStructure | undefined > | undefined
/** Hook: Called before each request execution */
readonly onBeforeRequest ? : ( context : ExecutionContext ) = > Promise < void >
/** Hook: Called after each request execution */
readonly onAfterRequest ? : ( context : ExecutionContext ) = > Promise < void >
/** Hook: Initialize extension state before test suite runs */
readonly onSuiteStart ? : ( config : TestConfig ) = >
Promise < Record < string , unknown > | undefined > | Record < string , unknown > | undefined
/** Hook: Cleanup after test suite completes */
readonly onSuiteEnd ? : ( suite : TestSuite , extensionState : Record < string , unknown > ) = > Promise < void >
/** Hook: Called when a contract violation is detected */
readonly onViolation ? : ( violation : ContractViolation , extensionState : Record < string , unknown > ) = > Promise < void >
}
```
### 3.2 Predicate Resolver
``` typescript
interface PredicateContext {
readonly route : RouteContract
readonly evalContext : EvalContext
readonly accessor : string [ ]
readonly extensionState : Record < string , unknown >
}
interface PredicateResult {
readonly value : unknown
readonly success : boolean
readonly error? : string
}
type PredicateResolver = ( context : PredicateContext ) = >
PredicateResult | Promise < PredicateResult >
```
---
## 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 < typeof createArbiter >
}
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 < string , unknown > | 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 < string , unknown > ) [ 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 < typeof createArbiter > }
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 < typeof createArbiter > }
// 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 < typeof createArbiter >
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' : [
2026-03-10 00:00:00 -07:00
// Behavioral: returned user must match the requested id
'response_body(this).id == request_params(this).id' ,
2026-03-10 00:00:00 -07:00
'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 *