# NEXT_STEPS_425.md — Post-v1.1 Integration Feedback & Priorities ## Status: v1.3 Complete (2026-04-25) **Test count**: 551 passing, 0 failures **New in v1.3**: All 8 protocol extensions (JWT, Time, Stateful, X.509, SPIFFE, Token Hash, HTTP Signature, Request Context), Plugin Contract System with lazy extension resolution, built-in plugin contracts for `@fastify/auth`, `@fastify/cors`, `@fastify/compress`, `@fastify/rate-limit` ## Completed ### v1.2 (2026-04-25) - [x] **F1**: APOSTL `else` is optional — defaults to `else T` - [x] **F2**: Value proposition comparison table in README and skills.md - [x] **F3**: Auth extension factory — `createAuthExtension()` - [x] **F4**: ContractViolation includes full request/response context - [x] **F5**: Fastify App Structure Guide - [x] **Chaos Mode**: Content-type aware corruption with extension strategies (21 tests) ### v1.3 (2026-04-25) - [x] **P0.1**: JWT Extension — claims, headers, format detection, Base64URL decode, `seen_jtis` tracking - [x] **P0.2**: Time Control — `now()` predicate + `createTimeControl().advance()` API - [x] **P1.1**: Stateful predicates — `already_seen()`, `is_consumed()`, `previous(category)` - [x] **P1.2**: X.509 Extension — URI SANs, CA check, expiration, self-signed, issuer, subject - [x] **P2.1**: SPIFFE Extension — trust domain, path, validation - [x] **P2.2**: Token Hash Extension — `ath_valid()`, `tth_valid()`, `token_hash()` - [x] **P2.3**: HTTP Signature Extension — `signature_input()`, `signature_covers()` - [x] **P2.4**: Request Context — `request_url()`, `request_tls()`, `request_body_hash()` - [x] **Plugin Contract System** — Registry, pattern matching, composition, lazy extension resolution - [x] **Built-in Plugin Contracts** — `@fastify/auth`, `@fastify/cors`, `@fastify/compress`, `@fastify/rate-limit` - [x] **Extension Registry Link** — `setPluginContractRegistry()` notifies on extension registration - [x] **Runner Integration** — Plugin contracts composed with route contracts in `petit-runner.ts` ## Expert Assessment Remediation Plan ### Chaos Engineering (Critical — Grade: F) #### Issue C1: Two-Level Probability Bug **Location**: `src/quality/chaos.ts:55, 82` **Problem**: Global gate at line 55 applies `config.probability`, then `pickEventType()` at line 82 applies per-event probability. Actual injection rate = `config.probability * eventProbability`, not `eventProbability`. **Pseudocode Fix**: ``` FUNCTION executeWithChaos(executeHttp, route, request, extensionRegistry): assertTestEnv('Chaos mode') this.events = [] // Pick event type using weighted probabilities (no global gate) eventType = this.pickEventType() IF eventType IS NULL: ctx = await executeHttp() RETURN { ctx, events: [] } // Apply the selected event directly SWITCH eventType: CASE 'delay': RETURN this.injectDelay(executeHttp) CASE 'dropout': RETURN this.injectDropout(route, request) CASE 'error': RETURN this.injectError(executeHttp, route, request) CASE 'corruption': RETURN this.injectCorruption(executeHttp, route, request, extensionRegistry) ``` **Invariants**: - MUST: The probability of event type X being injected MUST equal `config[X].probability / sum(all configured probabilities)` - MUST: The `pickEventType()` function MUST be the sole probability gate; no secondary filtering - MAY NEVER: A global probability gate AND per-event probability multiply #### Issue C2: `Math.random()` in Corruption Breaks Determinism **Location**: `src/quality/corruption.ts:165` **Problem**: `rng ?? new SeededRng(Date.now())` uses `Date.now()` when no RNG provided, making corruption non-deterministic. **Pseudocode Fix**: ``` FUNCTION corruptResponse(ctx, contentType, extensionRegistry, rng): // MUST receive RNG from caller; no fallback to Math.random() or Date.now() ASSERT rng IS NOT NULL, "corruptResponse requires injected SeededRng" // Check extension-provided strategies first IF extensionRegistry HAS strategy FOR contentType: RETURN applyExtensionStrategy(ctx, strategy, rng) // Fall back to built-in strategies using injected RNG baseType = contentType.split(';')[0].trim() builtin = BUILTIN_STRATEGIES[baseType] IF builtin EXISTS: RETURN { ctx: applyCorruption(ctx, (data) => builtin.strategy(data, rng), contentType), strategy: builtin.name, description: builtin.description } // Generic fallback with injected RNG RETURN { ctx: applyCorruption(ctx, (data) => truncateText(data, rng), contentType), strategy: 'generic-truncate', description: 'Generic truncation' } ``` **Invariants**: - MUST: `corruptResponse` MUST require `rng` parameter (non-optional) - MUST: All corruption strategies MUST use the injected `rng`, never `Math.random()` or `Date.now()` - MAY NEVER: Corruption produce different results for the same seed across runs #### Issue C3: Seed Collision Risk in ChaosEngine **Location**: `src/quality/chaos.ts:39` **Problem**: `seed + 0xCA05` can collide for nearby seeds. **Pseudocode Fix**: ``` CONSTRUCTOR(config, seed): this.config = config // Use hash-based seed derivation to avoid collisions IF seed IS DEFINED: chaosSeed = hashCombine(seed, 0xCA05) ELSE: chaosSeed = Date.now() // Only for undefined seed this.rng = new SeededRng(chaosSeed) FUNCTION hashCombine(a, b): // FNV-1a inspired combination hash = 0x811c9dc5 hash = ((hash ^ (a & 0xFF)) * 0x01000193) >>> 0 hash = ((hash ^ ((a >>> 8) & 0xFF)) * 0x01000193) >>> 0 hash = ((hash ^ ((a >>> 16) & 0xFF)) * 0x01000193) >>> 0 hash = ((hash ^ ((a >>> 24) & 0xFF)) * 0x01000193) >>> 0 hash = ((hash ^ (b & 0xFF)) * 0x01000193) >>> 0 hash = ((hash ^ ((b >>> 8) & 0xFF)) * 0x01000193) >>> 0 hash = ((hash ^ ((b >>> 16) & 0xFF)) * 0x01000193) >>> 0 hash = ((hash ^ ((b >>> 24) & 0xFF)) * 0x01000193) >>> 0 RETURN hash ``` **Invariants**: - MUST: Chaos seed derivation MUST use a hash function, not simple addition - MUST: Different test seeds MUST produce different chaos sequences with high probability - MAY NEVER: Two test seeds within 100,000 of each other produce identical chaos behavior ### Runtime Hook Safety (Critical) #### Issue H1: Hook Validator Throws 500s for Formula Parse Errors **Location**: `src/infrastructure/hook-validator.ts:89-93, 101` **Problem**: `preParseFormulas` throws on parse error, which becomes a 500 in Fastify hooks instead of failing at plugin registration time. **Pseudocode Fix**: ``` // Split into two phases: // Phase 1: Registration-time validation (in plugin/index.ts) FUNCTION validateAllContracts(routes): FOR EACH route IN routes: FOR EACH formula IN route.requires + route.ensures: TRY: parse(formula) CATCH err: THROW Error(`Invalid formula in ${route.method} ${route.path}: ${formula}\n${err.message}`) // Pre-parse and cache ASTs at registration time routeAsts = new Map() FOR EACH route IN routes: routeAsts.set(route, { requires: route.requires.map(f => parse(f).ast), ensures: route.ensures.map(f => parse(f).ast) }) RETURN routeAsts // Phase 2: Hook uses pre-parsed ASTs (O(1), never throws) FUNCTION createPreHandler(opts, routeAsts): RETURN (request, reply, done) => contract = getRouteContract(request) IF shouldSkipRoute(contract, opts): done() RETURN asts = routeAsts.get(contract)?.requires IF NOT asts: done() // No requires to check RETURN context = buildPreContext(request) evaluateFormulas(context, asts, contract.requires) done() ``` **Invariants**: - MUST: All formulas MUST be parsed at plugin registration time, not at request time - MUST: Parse errors MUST fail plugin registration with a clear error message - MAY NEVER: A request-time hook throw a 500 due to formula syntax error - MAY NEVER: Formula parsing happen on the request hot path #### Issue H2: env-guard Throws at Runtime Instead of Registration **Location**: `src/quality/env-guard.ts:8-14` **Problem**: `assertTestEnv` throws when chaos/flake/mutation are first used, not when configured. **Pseudocode Fix**: ``` // At plugin registration time (src/plugin/index.ts): FUNCTION registerApophis(fastify, opts): IF opts.chaos IS DEFINED AND process.env.NODE_ENV !== 'test': THROW Error('Chaos mode requires NODE_ENV=test') IF opts.flake IS DEFINED AND process.env.NODE_ENV !== 'test': THROW Error('Flake detection requires NODE_ENV=test') IF opts.mutation IS DEFINED AND process.env.NODE_ENV !== 'test': THROW Error('Mutation testing requires NODE_ENV=test') // ... rest of registration // Remove assertTestEnv from runtime paths entirely // Quality features are only constructed in test environment ``` **Invariants**: - MUST: Environment validation MUST happen at plugin registration time - MUST: Quality feature configuration in non-test env MUST prevent plugin startup - MAY NEVER: A runtime quality feature throw an environment error during test execution ### Architecture & Design (Martin Fowler / Uncle Bob) #### Issue A1: petit-runner.ts Violates SRP (583 lines) **Location**: `src/test/petit-runner.ts` **Problem**: Single file handles command generation, precondition checking, HTTP execution, chaos injection, flake detection, postcondition validation, deduplication, and result formatting. **Pseudocode Fix**: ``` // Extract into focused modules: // src/test/command-generator.ts (lines 92-149) FUNCTION generateCommands(routes, depth, seed): // Pure: cache lookup, schema conversion, fast-check sampling RETURN { commands, cacheHits, cacheMisses } // src/test/precondition-checker.ts (lines 155-165) FUNCTION checkPreconditions(command, state): // Pure: check resource existence RETURN boolean // src/test/chaos-wrapper.ts (lines 288-298) FUNCTION executeWithChaos(chaosEngine, executeFn, route, request, extensionRegistry): // Effect: inject chaos if enabled RETURN { ctx, chaosEvents } // src/test/flake-detector.ts (lines 341-412) FUNCTION detectFlake(failingResult, rerunFn, config, extensionRegistry, pluginContractRegistry): // Effect: rerun with varied seeds RETURN flakeReport // src/test/postcondition-validator.ts (lines 324-339, 400-408) FUNCTION validatePostconditionsWithPlugins(route, ctx, pluginContractRegistry, extensionRegistry): // Pure: compose plugin + route contracts, validate RETURN validationResult // src/test/result-deduplicator.ts (lines 489-528) FUNCTION deduplicateFailures(results): // Pure: group by route+formula, keep first RETURN dedupedResults // src/test/petit-runner.ts (reduced to ~150 lines) FUNCTION runPetitTests(fastify, config, scopeRegistry, extensionRegistry, pluginContractRegistry): // Orchestrator: delegates to extracted modules routes = discoverAndFilterRoutes(fastify, config) { commands, cacheHits, cacheMisses } = generateCommands(routes, depth, config.seed) FOR EACH command IN commands: IF NOT checkPreconditions(command, state): results.push(SKIP) CONTINUE { ctx, chaosEvents } = await executeWithChaos(...) validation = validatePostconditionsWithPlugins(...) IF NOT validation.success: flakeReport = await detectFlake(...) results.push(FAILURE with diagnostics) ELSE: results.push(SUCCESS) state = updateModelState(command.route, ctx, state) RETURN formatSuite(results, deduplicateFailures) ``` **Invariants**: - MUST: No test runner module exceed 200 lines of code - MUST: Each module have a single, clearly stated responsibility - MUST: Orchestrator modules contain only delegation logic, no implementation - MAY NEVER: A module mix pure computation with side effects #### Issue A2: stateful-runner.ts Duplicates petit-runner Logic **Location**: `src/test/stateful-runner.ts:54-64, 66-72` **Problem**: Precondition checking (`ApiOperation.check`) and HTTP execution (`ApiOperation.run`) duplicate petit-runner logic. **Pseudocode Fix**: ``` // Extract shared operations: // src/test/operations.ts CLASS ApiOperation: CONSTRUCTOR(route, params): this.route = route this.params = params check(model): RETURN checkPreconditions(this.route, model) // Shared with petit-runner async run(real): request = buildRequest(this.route, this.params, real.scopeHeaders, real.state, real.rng) ctx = await executeHttp(real.fastify, this.route, request, real.previousCtx) real.history.push(ctx) real.previousCtx = ctx // src/test/stateful-runner.ts IMPORT { ApiOperation } from './operations.js' IMPORT { checkPreconditions } from './precondition-checker.js' // Stateful runner focuses on fast-check command() integration only ``` **Invariants**: - MUST: Precondition checking logic exist in exactly one module - MUST: HTTP execution logic exist in exactly one module - MAY NEVER: Two runners implement the same logic differently #### Issue A3: Plugin Entry Point is God Object Factory **Location**: `src/plugin/index.ts` **Problem**: Lines 24-48 do 7 things: swagger registration, spec building, contract testing, stateful testing, health checking, route capture, and cleanup. **Pseudocode Fix**: ``` // src/plugin/swagger.ts FUNCTION registerSwagger(fastify, opts): IF fastify HAS swagger: RETURN swagger = await import('@fastify/swagger') await fastify.register(swagger.default, opts.swagger ?? {}) // src/plugin/spec-builder.ts FUNCTION buildSpec(fastify): routes = discoverRoutes(fastify) spec = fastify.swagger() RETURN { ...spec, 'x-apophis-contracts': routes.map(...) } // src/plugin/contract-runner.ts FUNCTION buildContract(fastify, scope, extensionRegistry, pluginContractRegistry): RETURN async (opts) => config = normalizeConfig(opts) suite = await runPetitTests(fastify, config, scope, extensionRegistry, pluginContractRegistry) validateNonEmptyDiscovery(suite, fastify) RETURN suite // src/plugin/index.ts (reduced to ~80 lines) FUNCTION apophisPlugin(fastify, opts): await registerSwagger(fastify, opts) scopeRegistry = new ScopeRegistry() cleanupManager = new CleanupManager(fastify, scopeRegistry) extensionRegistry = createExtensionRegistry() pluginContractRegistry = createPluginContractRegistry() fastify.decorate('apophis', { scope: scopeRegistry, contract: buildContract(fastify, scopeRegistry, extensionRegistry, pluginContractRegistry), stateful: buildStateful(fastify, scopeRegistry, cleanupManager, extensionRegistry, pluginContractRegistry), check: buildCheck(fastify, scopeRegistry, extensionRegistry, pluginContractRegistry), spec: buildSpec(fastify), capture: captureRoute(fastify), cleanup: cleanupManager, extend: extensionRegistry.register.bind(extensionRegistry), use: pluginContractRegistry.use.bind(pluginContractRegistry), }) ``` **Invariants**: - MUST: Plugin entry point only orchestrate, never implement - MUST: Each decoration factory live in its own module - MAY NEVER: A single function perform more than 3 distinct responsibilities ### Type Safety (Uncle Bob) #### Issue T1: `OperationHeader` Union with `string` Defeats Exhaustiveness **Location**: `src/types.ts:79-83` **Problem**: `| string` makes the union non-exhaustive; TypeScript can't verify all cases handled. **Pseudocode Fix**: ``` // src/types.ts // Remove the | string catch-all; use branded type for extensions export type CoreOperationHeader = | 'request_body' | 'response_body' | 'response_code' | 'request_headers' | 'response_headers' | 'query_params' | 'cookies' | 'response_time' | 'redirect_count' | 'redirect_url' | 'redirect_status' | 'timeout_occurred' | 'timeout_value' // Extension headers use branded type export type ExtensionHeader = string & { readonly __brand: 'ExtensionHeader' } export type OperationHeader = CoreOperationHeader | ExtensionHeader // Extension registration validates and brands headers: FUNCTION registerExtensionHeader(name: string): ExtensionHeader { IF NOT /^[a-z_][a-z0-9_]*$/.test(name): THROW Error(`Invalid extension header name: ${name}`) RETURN name as ExtensionHeader } ``` **Invariants**: - MUST: Core operation headers be exhaustively listable - MUST: Extension headers require explicit registration and validation - MAY NEVER: An unvalidated string be accepted as an operation header #### Issue T2: `RequestStructure.body?: unknown` is Lazy Typing **Location**: `src/domain/request-builder.ts:14` **Problem**: `unknown` provides no type safety for body construction. **Pseudocode Fix**: ``` // src/domain/request-builder.ts export type RequestBody = | { type: 'json'; data: Record } | { type: 'multipart'; fields: Record; files: MultipartFiles } | { type: 'text'; data: string } | undefined export interface RequestStructure { method: string url: string headers: Record query?: Record body?: RequestBody contentType?: string } // Build functions return discriminated union FUNCTION buildJsonRequest(route, data, scopeHeaders, state): RETURN { method: route.method, url: substitutePathParams(route.path, data, state), headers: { ...scopeHeaders, 'content-type': 'application/json' }, body: { type: 'json', data: extractBodyParams(data, route.schema.body) } } ``` **Invariants**: - MUST: Request body type be a discriminated union, not `unknown` - MUST: Body type determine serialization strategy - MAY NEVER: A body be accepted without knowing its content type ### Performance & Implementation (John Carmack) #### Issue P1: Hand-Rolled charCodeAt Parser (915 lines) **Location**: `src/formula/parser.ts` **Problem**: 915 lines of manual charCodeAt parsing is unmaintainable. Should use a parser generator or at least regex-based tokenizer. **Pseudocode Fix**: ``` // Option A: Use a parser generator (PEG.js / nearley) // grammar.ne: // main -> expression // expression -> comparison | boolean | conditional | quantified // comparison -> operation _ comparator _ operation // boolean -> expression _ ("&&" | "||" | "=>") _ expression // ... // Option B: Regex tokenizer + recursive descent (maintainable) // src/formula/tokenizer.ts (~100 lines) TOKEN_PATTERNS = [ { type: 'IF', pattern: /^if\b/ }, { type: 'THEN', pattern: /^then\b/ }, { type: 'ELSE', pattern: /^else\b/ }, { type: 'FOR', pattern: /^for\b/ }, { type: 'EXISTS', pattern: /^exists\b/ }, { type: 'IN', pattern: /^in\b/ }, { type: 'IDENTIFIER', pattern: /^[a-zA-Z_][a-zA-Z0-9_-]*/ }, { type: 'NUMBER', pattern: /^-?\d+(\.\d+)?/ }, { type: 'STRING', pattern: /^"([^"\\]|\\.)*"/ }, { type: 'COMPARATOR', pattern: /^(==|!=|<=|>=|<|>|matches)/ }, { type: 'BOOLEAN_OP', pattern: /^(&&|\|\||=>)/ }, { type: 'LPAREN', pattern: /^\(/ }, { type: 'RPAREN', pattern: /^\)/ }, { type: 'LBRACKET', pattern: /^\[/ }, { type: 'RBRACKET', pattern: /^\]/ }, { type: 'DOT', pattern: /^\./ }, { type: 'COMMA', pattern: /^,/ }, { type: 'WS', pattern: /^\s+/, skip: true } ] FUNCTION tokenize(input): tokens = [] pos = 0 WHILE pos < input.length: matched = FALSE FOR EACH pattern IN TOKEN_PATTERNS: match = input.slice(pos).match(pattern.pattern) IF match: IF NOT pattern.skip: tokens.push({ type: pattern.type, value: match[0], pos }) pos += match[0].length matched = TRUE BREAK IF NOT matched: THROW parseError(input, pos, `Unexpected character: ${input[pos]}`) RETURN tokens // src/formula/parser.ts (~200 lines, recursive descent on tokens) FUNCTION parse(tokens): pos = 0 FUNCTION peek(): RETURN tokens[pos] FUNCTION consume(expectedType): IF peek().type !== expectedType: THROW parseError(...) RETURN tokens[pos++] FUNCTION parseExpression(): RETURN parseConditional() FUNCTION parseConditional(): IF peek().type === 'IF': consume('IF') condition = parseBoolean() consume('THEN') thenBranch = parseExpression() consume('ELSE') elseBranch = parseExpression() RETURN { type: 'conditional', condition, then: thenBranch, else: elseBranch } RETURN parseBoolean() // ... etc RETURN parseExpression() ``` **Invariants**: - MUST: Parser be maintainable by developers without parser expertise - MUST: Tokenizer use regex patterns, not manual charCodeAt - MUST: Parser structure follow standard recursive descent pattern - MAY NEVER: A single parser file exceed 300 lines #### Issue P2: `hashSchema` Only Keeps 16 Chars of SHA-256 **Location**: `src/incremental/hash.ts:88` **Problem**: Truncating SHA-256 to 16 hex chars (64 bits) creates collision risk with large schemas. **Pseudocode Fix**: ``` // Use full hash or at least 32 chars (128 bits) FUNCTION hashSchema(schema): IF schema IS undefined: RETURN 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' // Empty SHA-256 cached = hashMemo.get(schema) IF cached IS NOT undefined: RETURN cached hash = createHash('sha256') FOR EACH key IN Object.keys(schema): IF NOT RELEVANT_KEYS.has(key): CONTINUE hash.update(key) hash.update('=') hashValue(hash, schema[key], new WeakSet()) hash.update(';') result = hash.digest('hex') // Full 64 chars hashMemo.set(schema, result) RETURN result ``` **Invariants**: - MUST: Schema hash use full SHA-256 output (64 hex chars) - MUST: Hash memoization use WeakMap to avoid memory leaks - MAY NEVER: Hash output be truncated below 256 bits #### Issue P3: `PARSE_CACHE` Map Has No TTL **Location**: `src/formula/parser.ts` (implied by cache pattern) **Problem**: Parsed formula ASTs accumulate indefinitely in memory. **Pseudocode Fix**: ``` // src/formula/parser.ts class LruCache: private cache = new Map() private maxSize: number CONSTRUCTOR(maxSize = 1000): this.maxSize = maxSize get(key): IF NOT this.cache.has(key): RETURN undefined value = this.cache.get(key) // Move to end (most recently used) this.cache.delete(key) this.cache.set(key, value) RETURN value set(key, value): IF this.cache.has(key): this.cache.delete(key) ELSE IF this.cache.size >= this.maxSize: // Evict least recently used (first item) firstKey = this.cache.keys().next().value this.cache.delete(firstKey) this.cache.set(key, value) const PARSE_CACHE = new LruCache(1000) FUNCTION parse(formula): cached = PARSE_CACHE.get(formula) IF cached IS NOT undefined: RETURN cached result = parseInternal(formula) PARSE_CACHE.set(formula, result) RETURN result ``` **Invariants**: - MUST: Parse cache use LRU eviction with configurable max size - MUST: Max cache size default to 1000 entries - MAY NEVER: Cache grow unbounded in long-running processes #### Issue P4: `Promise.race` in `executeHttp` Doesn't Cancel Inject **Location**: `src/infrastructure/http-executor.ts:104-113` **Problem**: When timeout wins the race, the `fastify.inject()` promise continues running, potentially causing memory leaks or side effects. **Pseudocode Fix**: ``` // Use AbortController for cancellation FUNCTION executeHttp(fastify, route, request, previous, timeoutMs): // ... setup ... const controller = new AbortController() let timeoutId: NodeJS.Timeout | undefined try: const injectPromise = fastify.inject({ method: request.method, url: fullUrl, payload: request.multipart ? buildMultipartPayload(request.multipart) : request.body, headers: request.headers, }) IF timeoutMs AND timeoutMs > 0: timeoutId = setTimeout(() => { timedOut = true controller.abort() }, timeoutMs) response = await injectPromise IF timeoutId: clearTimeout(timeoutId) CATCH err: IF timeoutId: clearTimeout(timeoutId) IF timedOut: RETURN buildTimeoutContext(...) THROW err // Note: Fastify inject() may not support AbortController. // Alternative: track active requests and clean up on suite end. ``` **Invariants**: - MUST: Timeout mechanism prevent resource leaks from abandoned requests - MUST: Active request tracking enable cleanup on test suite completion - MAY NEVER: A timed-out request continue consuming resources indefinitely #### Issue P5: Streaming NDJSON Loads Entire Response Into Memory **Location**: `src/infrastructure/http-executor.ts:170-186` **Problem**: `responseBody` is fully loaded as string, then split and parsed. No backpressure for large streams. **Pseudocode Fix**: ``` // For NDJSON streaming, use chunked processing IF isStreaming AND streamFormat === 'ndjson': const chunks = [] const maxChunks = streamConfig.maxChunks ?? 100 const maxChunkSize = streamConfig.maxChunkSize ?? 65536 // 64KB const maxTotalSize = streamConfig.maxTotalSize ?? 1048576 // 1MB let totalSize = 0 const lines = responseBody.split('\n') FOR EACH line IN lines: IF line.trim().length === 0: CONTINUE const lineSize = line.length IF lineSize > maxChunkSize: log.warn(`NDJSON chunk exceeds max size: ${lineSize} > ${maxChunkSize}`) CONTINUE totalSize += lineSize IF totalSize > maxTotalSize: log.warn(`NDJSON total size exceeds max: ${totalSize} > ${maxTotalSize}`) BREAK IF chunks.length >= maxChunks: log.warn(`NDJSON chunk count exceeds max: ${chunks.length} >= ${maxChunks}`) BREAK TRY: chunks.push(JSON.parse(line)) CATCH: chunks.push(line) // Keep raw line if not valid JSON RETURN { ...ctx, response: { ...ctx.response, chunks: chunks as readonly unknown[], streamDurationMs, truncated: lines.length > maxChunks || totalSize > maxTotalSize } } ``` **Invariants**: - MUST: NDJSON processing enforce max chunk count, chunk size, and total size limits - MUST: Exceeded limits truncate gracefully with warning, not error - MAY NEVER: Streaming response processing consume unbounded memory #### Issue P6: `request-builder.ts` Uses `Math.random()` as Fallback **Location**: `src/domain/request-builder.ts:112` **Problem**: When no RNG provided, falls back to `Math.random()` for path param selection. **Pseudocode Fix**: ``` // src/domain/request-builder.ts FUNCTION substitutePathParams(path, data, state, rng): url = path pathParams = parseRouteParams(path) FOR EACH param IN pathParams: value = data[param] IF value IS undefined AND param.endsWith('Id'): resourceType = param.replace(/Id$/, '').toLowerCase() resources = state.resources.get(resourceType) IF resources AND resources.size > 0: ids = Array.from(resources.keys()) IF rng IS DEFINED: value = rng.pick(ids) ELSE: // Deterministic fallback: use first ID (consistent across runs) value = ids[0] log.warn(`No RNG provided for path param selection; using deterministic fallback`) IF value IS NOT undefined: url = url.replace(`:${param}`, String(value)) RETURN url ``` **Invariants**: - MUST: Path param selection use injected RNG when available - MUST: Missing RNG produce deterministic, logged fallback - MAY NEVER: `Math.random()` be used in test generation paths #### Issue P7: Duplicate Sync/Async Evaluation Paths in `evaluator.ts` **Location**: `src/formula/evaluator.ts` **Problem**: Two parallel code paths for sync and async evaluation; easy to drift. **Pseudocode Fix**: ``` // Unify on async; sync is just async with no await FUNCTION evaluate(node, ctx, extensionRegistry): SWITCH node.type: CASE 'literal': RETURN node.value CASE 'variable': RETURN resolveVariable(node.name, ctx) CASE 'operation': resolver = extensionRegistry?.resolvePredicate(node.header) IF resolver: // Always async; await if needed result = resolver(ctx, node.parameter, node.accessor) IF result IS Promise: RETURN await result RETURN result RETURN resolveBuiltinOperation(node.header, ctx, node.accessor) CASE 'comparison': left = await evaluate(node.left, ctx, extensionRegistry) right = await evaluate(node.right, ctx, extensionRegistry) RETURN applyComparator(node.op, left, right) CASE 'boolean': left = await evaluate(node.left, ctx, extensionRegistry) // Short-circuit IF node.op === '&&' AND NOT left: RETURN false IF node.op === '||' AND left: RETURN true IF node.op === '=>'': IF NOT left: RETURN true // False antecedent = true RETURN await evaluate(node.right, ctx, extensionRegistry) right = await evaluate(node.right, ctx, extensionRegistry) RETURN applyBooleanOp(node.op, left, right) CASE 'conditional': condition = await evaluate(node.condition, ctx, extensionRegistry) IF condition: RETURN await evaluate(node.then, ctx, extensionRegistry) RETURN await evaluate(node.else, ctx, extensionRegistry) CASE 'quantified': collection = resolveCollection(node.collection, ctx) IF node.quantifier === 'for': FOR EACH item IN collection: result = await evaluate(node.body, ctx.withBinding(node.variable, item), extensionRegistry) IF NOT result: RETURN false RETURN true ELSE: // exists FOR EACH item IN collection: result = await evaluate(node.body, ctx.withBinding(node.variable, item), extensionRegistry) IF result: RETURN true RETURN false CASE 'previous': RETURN evaluate(node.inner, ctx.previous, extensionRegistry) CASE 'status': RETURN ctx.response.statusCode === node.code // Public API: always async export async function evaluateFormula(node, ctx, extensionRegistry): RETURN evaluate(node, ctx, extensionRegistry) // Backward compat: sync wrapper for simple cases export function evaluateFormulaSync(node, ctx, extensionRegistry): result = evaluate(node, ctx, extensionRegistry) IF result IS Promise: THROW Error('Sync evaluation encountered async predicate; use evaluateFormula()') RETURN result ``` **Invariants**: - MUST: Single evaluation implementation, not parallel sync/async paths - MUST: All evaluation be async by default; sync wrapper fail on async encounter - MAY NEVER: Sync and async paths diverge in behavior #### Issue P8: `topologicalSort` Re-sorts Entire Array on Every `register()` **Location**: `src/extension/registry.ts:159` **Problem**: O(n²) complexity when registering extensions one at a time. **Pseudocode Fix**: ``` // src/extension/registry.ts class ExtensionRegistryImpl: private _extensions: ApophisExtension[] = [] private _sorted = false register(extension): // Validate uniqueness IF this._extensions.some(e => e.name === extension.name): THROW Error(`Extension '${extension.name}' already registered`) // Add without sorting this._extensions.push(extension) this._sorted = false // Notify plugin contract registry IF this._pluginContractRegistry: this._pluginContractRegistry.registerAvailableExtension(extension.name) // Cache predicates and corruption strategies immediately this._cacheExtensionData(extension) // Lazy sort: only when hook arrays are needed private ensureSorted(): IF this._sorted: RETURN this._extensions = topologicalSort(this._extensions) this._rebuildHookArrays() this._sorted = true get extensions(): this.ensureSorted() RETURN this._extensions runBuildRequestHooks(ctx): this.ensureSorted() FOR EACH ext IN this._buildRequestExts: // ... run hook // ... other hook runners call ensureSorted() first ``` **Invariants**: - MUST: Extension registration be O(1) amortized - MUST: Sorting happen lazily, only when hooks are first accessed - MAY NEVER: Registration trigger full re-sort of all extensions #### Issue P9: `safe-regex` Has False Positives/Negatives, No Timeout Enforcement **Location**: `src/infrastructure/regex-guard.ts` **Problem**: `safe-regex` is a heuristic with known false positives and negatives. No actual ReDoS protection via timeout. **Pseudocode Fix**: ``` // src/infrastructure/regex-guard.ts import { Worker } from 'node:worker_threads' const SAFE_REGEX_TIMEOUT_MS = 1000 FUNCTION validateRegexPattern(pattern): TRY: // Fast heuristic check IF NOT safeRegex(pattern): RETURN { safe: false, reason: 'Pattern flagged by safe-regex heuristic', severity: 'exponential' } // Compile and test with timeout regex = new RegExp(pattern) // Test with a pathological input in a worker with timeout testResult = await testRegexWithTimeout(regex, SAFE_REGEX_TIMEOUT_MS) IF testResult.timedOut: RETURN { safe: false, reason: 'Pattern timed out during test (potential ReDoS)', severity: 'exponential' } RETURN { safe: true, severity: 'safe' } CATCH err: RETURN { safe: false, reason: `Validation error: ${err.message}`, severity: 'exponential' } FUNCTION testRegexWithTimeout(regex, timeoutMs): RETURN new Promise((resolve) => { const worker = new Worker(` const { parentPort } = require('worker_threads'); parentPort.once('message', ({ pattern, input }) => { const regex = new RegExp(pattern); const start = Date.now(); try { regex.test(input); parentPort.postMessage({ elapsed: Date.now() - start }); } catch (err) { parentPort.postMessage({ error: err.message }); } }); `, { eval: true }); const timer = setTimeout(() => { worker.terminate(); resolve({ timedOut: true }); }, timeoutMs); worker.once('message', (result) => { clearTimeout(timer); worker.terminate(); resolve(result); }); // Pathological input: repeated 'a' followed by 'b' worker.postMessage({ pattern: regex.source, input: 'a'.repeat(100) + 'b' }); }); ``` **Invariants**: - MUST: Regex validation include actual execution timeout test - MUST: Timeout test run in isolated worker thread - MUST: Patterns timing out be rejected regardless of heuristic result - MAY NEVER: A regex be accepted solely based on heuristic analysis #### Issue P10: Redaction Logic is Overly Broad **Location**: `src/extension/redaction.ts:48, 77` **Problem**: `lowerKey.includes(sensitive)` matches partial substrings (e.g., "authorization" matches "auth" but also false-positives on "author_name"). **Pseudocode Fix**: ``` // src/extension/redaction.ts const SENSITIVE_FIELDS = new Set([ 'authorization', 'x-api-key', 'x-auth-token', 'api-key', 'token', 'access_token', 'refresh_token', 'id_token', 'client_secret', 'cookie', 'session', 'sessionid', 'phpsessid', 'password', 'secret', 'private_key', 'api_secret', 'ssn', 'social_security', 'credit_card', 'creditcard', 'cvv', ]) FUNCTION isSensitiveField(key): lowerKey = key.toLowerCase() // Exact match IF SENSITIVE_FIELDS.has(lowerKey): RETURN true // Prefix match for known patterns (e.g., x-auth-token-xxx) FOR EACH sensitive IN SENSITIVE_FIELDS: IF lowerKey === sensitive OR lowerKey.startsWith(sensitive + '-') OR lowerKey.startsWith(sensitive + '_'): RETURN true RETURN false FUNCTION redactHeaders(headers): result = {} FOR EACH [key, value] IN Object.entries(headers): IF isSensitiveField(key): result[key] = '[REDACTED]' ELSE: result[key] = value RETURN result ``` **Invariants**: - MUST: Redaction use exact or prefix matching, not substring matching - MUST: Redaction set be explicitly listed, not dynamically generated - MAY NEVER: A non-sensitive field be redacted due to partial string match #### Issue P11: `substitutor.ts` PARAM_PATTERN Could Inject Arbitrary APOSTL **Location**: Implied by parameter substitution pattern **Problem**: If user input reaches parameter substitution without validation, arbitrary APOSTL formulas could be injected. **Pseudocode Fix**: ``` // src/domain/substitutor.ts (or request-builder.ts) const PARAM_PATTERN = /:([a-zA-Z_][a-zA-Z0-9_]*)/g FUNCTION substitutePathParams(path, data, state, rng): url = path FOR EACH match OF path.matchAll(PARAM_PATTERN): paramName = match[1] // Validate param name is alphanumeric + underscore only IF NOT /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(paramName): THROW Error(`Invalid path parameter name: ${paramName}`) value = data[paramName] // Sanitize value before substitution IF value IS NOT undefined: sanitized = String(value).replace(/[^a-zA-Z0-9_.~-]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`) url = url.replace(match[0], sanitized) RETURN url ``` **Invariants**: - MUST: Path parameter names be validated against whitelist - MUST: Parameter values be URL-encoded before substitution - MAY NEVER: Unsanitized user input be substituted into URLs ### Observability (Charity Majors) #### Issue O1: Zero OpenTelemetry Integration **Location**: Entire codebase **Problem**: No distributed tracing, no metrics, no correlation between CI test failures and production incidents. **Pseudocode Fix**: ``` // src/infrastructure/telemetry.ts import { trace, metrics, context } from '@opentelemetry/api' class ApophisTelemetry: private tracer = trace.getTracer('apophis', '1.3.0') private meter = metrics.getMeter('apophis', '1.3.0') // Counters private testsRun = this.meter.createCounter('apophis.tests.run') private testsPassed = this.meter.createCounter('apophis.tests.passed') private testsFailed = this.meter.createCounter('apophis.tests.failed') private testsFlaky = this.meter.createCounter('apophis.tests.flaky') private chaosEvents = this.meter.createCounter('apophis.chaos.events') private contractViolations = this.meter.createCounter('apophis.contract.violations') // Histograms private testDuration = this.meter.createHistogram('apophis.test.duration_ms') private requestDuration = this.meter.createHistogram('apophis.request.duration_ms') startTestSpan(testName, attributes): RETURN this.tracer.startSpan('apophis.test', { attributes: { 'apophis.test.name': testName, 'apophis.test.runner': 'petit', ...attributes } }) recordTestResult(span, result): this.testsRun.add(1) IF result.ok: this.testsPassed.add(1) ELSE: this.testsFailed.add(1) IF result.diagnostics?.flake?.isFlaky: this.testsFlaky.add(1) span.setAttribute('apophis.test.result', result.ok ? 'pass' : 'fail') span.end() recordChaosEvent(eventType): this.chaosEvents.add(1, { 'apophis.chaos.type': eventType }) recordContractViolation(formula, route): this.contractViolations.add(1, { 'apophis.contract.formula': formula, 'apophis.contract.route': route }) // Integration in petit-runner.ts FUNCTION runPetitTests(fastify, config, ...): telemetry = new ApophisTelemetry() FOR EACH command IN allCommands: span = telemetry.startTestSpan(command.route.path, { 'http.method': command.route.method, 'http.route': command.route.path }) TRY: ctx = await executeHttp(...) validation = validatePostconditions(...) IF NOT validation.success: telemetry.recordContractViolation(validation.formula, command.route.path) telemetry.recordTestResult(span, { ok: false }) ELSE: telemetry.recordTestResult(span, { ok: true }) CATCH err: span.recordException(err) telemetry.recordTestResult(span, { ok: false }) FINALLY: span.end() ``` **Invariants**: - MUST: Every test execution produce a trace span - MUST: Every contract violation produce a metric - MUST: Chaos events be counted and tagged by type - MUST: Flaky tests be distinguishable from consistent failures in metrics - MAY NEVER: A test run produce zero telemetry output #### Issue O2: No Per-Route Chaos Granularity **Location**: `src/quality/chaos.ts` **Problem**: Chaos config is global; cannot disable chaos for specific routes or apply different strategies per route. **Pseudocode Fix**: ``` // src/types.ts export interface ChaosConfig: probability: number delay?: { probability: number; minMs: number; maxMs: number } error?: { probability: number; statusCode: number; body?: unknown } dropout?: { probability: number } corruption?: { probability: number } // Per-route overrides routeOverrides?: Map> // key: "METHOD path" // Route matchers (regex patterns) excludeRoutes?: string[] // Routes to never chaos includeOnlyRoutes?: string[] // If set, only chaos these routes // src/quality/chaos.ts FUNCTION shouldApplyChaos(route, config): routeKey = `${route.method} ${route.path}` // Check exclusions IF config.excludeRoutes: FOR EACH pattern IN config.excludeRoutes: IF new RegExp(pattern).test(routeKey): RETURN false // Check inclusions IF config.includeOnlyRoutes: matched = false FOR EACH pattern IN config.includeOnlyRoutes: IF new RegExp(pattern).test(routeKey): matched = true BREAK IF NOT matched: RETURN false RETURN true FUNCTION executeWithChaos(executeHttp, route, request, extensionRegistry): IF NOT shouldApplyChaos(route, this.config): ctx = await executeHttp() RETURN { ctx, events: [] } // Merge route-specific config routeKey = `${route.method} ${route.path}` routeConfig = this.config.routeOverrides?.get(routeKey) ?? {} effectiveConfig = { ...this.config, ...routeConfig } // ... rest of chaos logic using effectiveConfig ``` **Invariants**: - MUST: Chaos support per-route enable/disable - MUST: Route matching use regex patterns for flexibility - MUST: Per-route config override global config for that route - MAY NEVER: Chaos be applied to excluded routes #### Issue O3: No Resilience Verification After Chaos **Location**: `src/quality/chaos.ts` **Problem**: Chaos injects failures but doesn't verify the system recovers (resilience testing). **Pseudocode Fix**: ``` // src/quality/chaos.ts FUNCTION executeWithChaos(executeHttp, route, request, extensionRegistry): // ... inject chaos ... // After chaos injection, verify system health IF this.config.resilienceCheck: healthResult = await this.checkResilience(route, request, extensionRegistry) IF NOT healthResult.healthy: this.events.push({ type: 'resilience_failure', injected: false, details: { reason: `System did not recover after ${eventType}: ${healthResult.reason}`, recoveryTimeMs: healthResult.recoveryTimeMs } }) RETURN { ctx, events: this.events } FUNCTION checkResilience(route, request, extensionRegistry): startTime = Date.now() // Retry the same request without chaos retryCtx = await executeHttp() // Check if response is valid validation = validatePostconditions(route.ensures, retryCtx, route, extensionRegistry) recoveryTimeMs = Date.now() - startTime IF validation.success AND retryCtx.response.statusCode < 500: RETURN { healthy: true, recoveryTimeMs } ELSE: RETURN { healthy: false, recoveryTimeMs, reason: validation.error ?? `HTTP ${retryCtx.response.statusCode}` } ``` **Invariants**: - MUST: Chaos mode optionally verify system recovery after injection - MUST: Resilience check measure recovery time - MUST: Resilience failures be reported as distinct event type - MAY NEVER: Chaos injection silently leave system in degraded state #### Issue O4: Runtime Hooks Evaluate on EVERY Request **Location**: `src/infrastructure/hook-validator.ts:110-128, 135-153` **Problem**: Hooks run on every request in production, adding overhead even for routes with no contracts. **Pseudocode Fix**: ``` // src/infrastructure/hook-validator.ts FUNCTION registerValidationHooks(fastify, opts, routes): // Pre-filter: only routes with contracts need hooks contractRoutes = routes.filter(r => hasContractAnnotations(r) AND r.validateRuntime) IF contractRoutes.length === 0: log.info('No runtime validation hooks registered (no contracts with validateRuntime)') RETURN // Pre-parse all formulas at registration time routeAsts = new Map() FOR EACH route IN contractRoutes: routeAsts.set(`${route.method} ${route.path}`, { requires: route.requires.map(f => parse(f).ast), ensures: route.ensures.map(f => parse(f).ast) }) // Register hooks only for routes with contracts fastify.addHook('preHandler', createPreHandler(opts, routeAsts)) fastify.addHook('preSerialization', createPreSerializer()) fastify.addHook('onSend', createOnSend(opts, routeAsts)) log.info(`Registered runtime validation for ${contractRoutes.length} routes`) ``` **Invariants**: - MUST: Hooks only register for routes with runtime validation enabled - MUST: Routes without contracts incur zero hook overhead - MUST: Registration log count of hooked routes - MAY NEVER: A route without contracts trigger hook execution ### Category Inference (Martin Fowler) #### Issue Cat1: Hardcoded Exact Paths Miss Prefixed Variants **Location**: `src/domain/category.ts:12-47` **Problem**: `/api/health`, `/v1/health`, `/internal/health` are not recognized as utility paths. **Pseudocode Fix**: ``` // src/domain/category.ts const UTILITY_PATTERNS = [ /^\/?(api\/)?(v\d+\/)?(reset|health|ping|login|logout|auth|callback|purge|clear|initialize|setup|webhook)\/?$/i, ] const isUtilityPath = (path): FOR EACH pattern IN UTILITY_PATTERNS: IF pattern.test(path): RETURN true RETURN false // Or use suffix matching const UTILITY_SUFFIXES = new Set([ 'reset', 'health', 'ping', 'login', 'logout', 'auth', 'callback', 'purge', 'clear', 'initialize', 'setup', 'webhook' ]) const isUtilityPath = (path): // Remove leading/trailing slashes and version prefixes normalized = path.replace(/^\//, '').replace(/\/$/, '') segments = normalized.split('/') lastSegment = segments[segments.length - 1] // Check if last segment is a known utility suffix IF UTILITY_SUFFIXES.has(lastSegment.toLowerCase()): RETURN true // Check exact matches for root-level paths RETURN UTILITY_SUFFIXES.has(normalized.toLowerCase()) ``` **Invariants**: - MUST: Category inference recognize utility paths regardless of prefix - MUST: Path normalization handle leading/trailing slashes and version segments - MAY NEVER: A `/api/health` route be categorized as non-utility ### APOSTL Formula Language (Martin Fowler) #### Issue F1: No Arithmetic Operators **Location**: `src/formula/parser.ts`, `src/formula/evaluator.ts` **Problem**: APOSTL lacks `+`, `-`, `*`, `/` operators, limiting expressiveness. **Pseudocode Fix**: ``` // src/types.ts export type FormulaNode = | ...existing nodes... | { type: 'arithmetic'; op: '+' | '-' | '*' | '/'; left: FormulaNode; right: FormulaNode } // src/formula/parser.ts // Add to grammar: // expression -> additive // additive -> multiplicative (('+' | '-') multiplicative)* // multiplicative -> unary (('*' | '/') unary)* // unary -> ('-' unary) | primary FUNCTION parseExpression(): RETURN parseAdditive() FUNCTION parseAdditive(): left = parseMultiplicative() WHILE peek().type IN ['+', '-']: op = consume(peek().type).type right = parseMultiplicative() left = { type: 'arithmetic', op, left, right } RETURN left FUNCTION parseMultiplicative(): left = parseUnary() WHILE peek().type IN ['*', '/']: op = consume(peek().type).type right = parseUnary() left = { type: 'arithmetic', op, left, right } RETURN left FUNCTION parseUnary(): IF peek().type === '-': consume('-') operand = parseUnary() RETURN { type: 'arithmetic', op: '*', left: { type: 'literal', value: -1 }, right: operand } RETURN parsePrimary() // src/formula/evaluator.ts CASE 'arithmetic': left = await evaluate(node.left, ctx, extensionRegistry) right = await evaluate(node.right, ctx, extensionRegistry) IF typeof left !== 'number' OR typeof right !== 'number': THROW Error(`Arithmetic operator ${node.op} requires numeric operands`) SWITCH node.op: CASE '+': RETURN left + right CASE '-': RETURN left - right CASE '*': RETURN left * right CASE '/': IF right === 0: THROW Error('Division by zero') RETURN left / right ``` **Invariants**: - MUST: Arithmetic operators support `+`, `-`, `*`, `/` - MUST: Arithmetic require numeric operands - MUST: Division by zero produce clear error - MAY NEVER: Arithmetic operators accept non-numeric operands silently ## Remaining ### Medium Priority - [ ] **F6**: CI/CD examples (`docs/ci-cd.md`) — GitHub Actions, GitLab CI, CircleCI workflows ### Quality Features (Phase 2-3) - [x] **Flake Detection** (`src/quality/flake.ts`) — Auto-rerun failing tests with varied seeds - [ ] **Mutation Testing** (`src/quality/mutation.ts`) — Synthetic bug injection, contract strength scoring ## Metrics | Metric | v1.1 | v1.2 | v1.3 | Target | |--------|------|------|------|--------| | Tests passing | 482 | 502 | 551 | 551+ | | Protocol extensions | 0 | 0 | 8 | 8 | | Plugin contracts | 0 | 0 | 4 built-in | 4+ | | Chaos mode | 0 | 1 engine | 1 engine | 1 engine | | Flake detection | 0 | 0 | 0 | Auto-rerun | | Mutation testing | 0 | 0 | 0 | Score reporting | | CI/CD examples | 0 | 0 | 0 | 3 workflows | ## v2.0: APOSTL → Justin (Subscript) Migration ### Decision: Migrate to Justin Expression Language **Rationale**: Pre-release, minimal internal adoption. Perfect time for clean break. Justin provides: - Arithmetic, null coalescing, optional chaining (free) - "Just JS" syntax — no new DSL to learn - ~3KB bundle (smaller than custom parser) - Sandboxed execution (no `__proto__`, `constructor`, `eval`) - IDE support out of the box (ESLint, Prettier, syntax highlighting) ### Design Decisions 1. **Property naming**: HTTP standard names (`statusCode`, `request.body`, `response.headers`) 2. **Previous context**: First-class via `previous` object (`previous.response.statusCode`) 3. **Extensions**: Register as context methods and variables (not operation headers) 4. **Quantifiers**: Built-in array methods (`every`, `some`, `find`, `filter`) 5. **Bundle size**: Acceptable (net reduction after deleting custom parser) 6. **Rigor**: Despite JS syntax, still extract invariants and implications for model testing ### Schema Annotation Changes ```javascript // BEFORE: APOSTL x-ensures: 'response_body(this).name == "John"' x-requires: 'response_code(this) == 200' x-requires: 'for item in response_body: item.price > 0' // AFTER: Justin x-ensures: 'response.body.name == "John"' x-requires: 'statusCode == 200' x-requires: 'response.body.every(item => item.price > 0)' ``` ### Context Mapping Justin receives a flat context built from `EvalContext`: ```javascript { // Request properties request: { body: ctx.request.body, headers: ctx.request.headers, query: ctx.request.query, params: ctx.request.params, cookies: ctx.request.cookies, multipart: ctx.request.multipart }, // Response properties response: { body: ctx.response.body, headers: ctx.response.headers, statusCode: ctx.response.statusCode, status: ctx.response.statusCode, // alias responseTime: ctx.response.responseTime, chunks: ctx.response.chunks, streamDurationMs: ctx.response.streamDurationMs }, // Redirects redirects: ctx.redirects, redirectCount: ctx.redirects?.length, // Timeout timedOut: ctx.timedOut, timeoutMs: ctx.timeoutMs, // Previous context (for temporal assertions) previous: ctx.previous ? buildContext(ctx.previous) : null } ``` ### Extension Integration Extensions register context variables and methods: ```javascript // Extension registers itself extensionRegistry.register({ name: 'jwt', context: { // Variables jwtClaims: (ctx) => parseJwt(ctx.request.headers.authorization), jwtExpired: (ctx) => isJwtExpired(ctx.request.headers.authorization), // Methods jwtHasClaim: (ctx, claim) => { const claims = parseJwt(ctx.request.headers.authorization) return claims?.[claim] !== undefined } } }) // Used in formulas: // x-ensures: 'jwtClaims.role == "admin"' // x-requires: 'jwtHasClaim("sub")' ``` ### Manual Migration Approach No automated migration scripts. All formula conversions done by hand to ensure correctness and take advantage of Justin's richer syntax. **Conversion examples:** ```javascript // APOSTL: Justin: response_body(this).name response.body.name response_code(this) == 200 statusCode == 200 status:200 statusCode == 200 T true F false a => b !a || b x matches /regex/ /regex/.test(x) for item in arr: item.price > 0 arr.every(item => item.price > 0) exists item in arr: item.ok arr.some(item => item.ok) previous(response_body(this)) previous.response.body ``` ### Files to Delete - `src/formula/parser.ts` (316 lines) - `src/formula/tokenizer.ts` (170 lines) - `src/formula/evaluator.ts` (421 lines) - `src/formula/substitutor.ts` (75 lines) - `src/types.ts`: `FormulaNode`, `Comparator`, `BooleanOperator`, `OperationHeader`, `OperationParameter`, `OperationCall`, `ParseResult` types ### Files to Create - `src/formula/justin.ts` (~80 lines) - Wraps `subscript/justin.js` - Builds context from `EvalContext` - Adds `matches` operator via subscript extension API - `src/formula/context-builder.ts` (~50 lines) - Maps `EvalContext` → flat context object - Handles `previous` nesting - `src/formula/justin-context.ts` (~30 lines) - Type definitions for Justin evaluation context ### Files to Modify - `package.json`: Add `subscript` dependency - `src/types.ts`: - Replace `ValidatedFormula` with `string` - Remove APOSTL-specific types - Add `JustinContext` interface - `src/infrastructure/hook-validator.ts`: - Replace `evaluateBooleanResult` with Justin evaluation - Pre-compile formulas at registration time using `subscript` - `src/domain/contract-validation.ts`: - Replace APOSTL evaluation with Justin - `src/extension/types.ts`: - Change extension predicate API to context variables/methods - `src/extension/registry.ts`: - Build combined context from all extensions - All test files with APOSTL formulas (~40 test files) ### Invariant Extraction Despite JS syntax, we still extract logical invariants for property-based testing: ```javascript // Formula: 'statusCode == 200 && response.body.id != null' // Extracted invariants: // - statusCode ∈ {200} // - response.body.id ≠ null // - response.body has property 'id' // Formula: 'request.body.price * request.body.quantity <= 10000' // Extracted invariants: // - request.body.price is number // - request.body.quantity is number // - request.body.price * request.body.quantity ≤ 10000 ``` ### Implementation Order 1. Add `subscript` dependency 2. Create `justin.ts` and `context-builder.ts` 3. Modify `types.ts` (remove APOSTL types, add Justin types) 4. Modify `hook-validator.ts` and `contract-validation.ts` 5. Update extension API (context variables/methods) 6. Hand-convert all schema annotations in test files 7. Update all test assertions 8. Delete old parser/evaluator/tokenizer/substitutor files 9. Verify all tests pass ## Reference - **Protocol Extensions Spec**: `docs/protocol-extensions-spec.md` - **Plugin Contracts Spec**: `docs/PLUGIN_CONTRACTS_SPEC.md` - **Quality Features Plan**: `docs/QUALITY_FEATURES_PLAN.md` - **CHANGELOG**: `CHANGELOG.md` - **Subscript/Justin**: https://github.com/dy/subscript