Skip to content

Commit e3b5b76

Browse files
committed
feat: Implement Approval System with runtime enforcement and error handling
- Added new section in `PROTOCOL_SPEC.md` for Approval System, detailing the `ApprovalHandler` protocol and related data types. - Updated `changelog.md` for version 0.3.0, documenting new features and changes. - Enhanced `executor-api.md` to include `approval_handler` parameter and related error types. - Created `approval-system.md` to outline the Approval System's design and requirements. - Modified `module-interface.md` to clarify `requires_approval` annotation behavior. - Updated `core-executor.md` to describe the new Approval Gate in the execution pipeline. - Adjusted `README.md` and `mkdocs.yml` to include references to the Approval System documentation.
1 parent 68b5229 commit e3b5b76

File tree

8 files changed

+504
-59
lines changed

8 files changed

+504
-59
lines changed

PROTOCOL_SPEC.md

Lines changed: 306 additions & 56 deletions
Large diffs are not rendered by default.

changelog.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
---
99

10+
## [0.3.0] - 2026-03-01
11+
12+
### Added
13+
14+
#### Protocol Specification
15+
- **Approval System (§7)** — new section in `PROTOCOL_SPEC.md` defining the `ApprovalHandler` protocol, `ApprovalRequest`/`ApprovalResult` data types, Executor Step 4.5 integration, error types (`APPROVAL_DENIED`, `APPROVAL_TIMEOUT`, `APPROVAL_PENDING`), built-in handlers, protocol bridge handlers, phased implementation (Phase A sync, Phase B async), and conformance levels
16+
17+
#### Feature Documentation
18+
- `docs/features/approval-system.md` — full specification of the Approval System feature
19+
20+
### Changed
21+
- **`PROTOCOL_SPEC.md`** — bumped to v1.3.0-draft; updated `requires_approval` annotation description to reference runtime enforcement; added approval error codes and error hierarchy; renumbered §7–§13 → §8–§14
22+
- **`docs/api/executor-api.md`** — added `approval_handler` constructor parameter, `ApprovalDeniedError`/`ApprovalTimeoutError` to error types, Step 4.5 to execution flow and state machine
23+
- **`docs/api/module-interface.md`** — updated `requires_approval` annotation description to reference Approval System and runtime enforcement
24+
- **`docs/features/core-executor.md`** — added Step 4.5 (Approval Gate) to the execution pipeline
25+
- **`docs/README.md`** — added Approval System to feature specifications table, directory tree, and concept index
26+
27+
---
28+
1029
## [0.2.0] - 2026-02-23
1130

1231
### Added

docs/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ docs/
2020
│ └── executor-api.md ← Executor
2121
├── features/ ← Feature specifications (for SDK implementors)
2222
│ ├── acl-system.md ← Access Control System
23+
│ ├── approval-system.md ← Approval System
2324
│ ├── core-executor.md ← Core Execution Engine
2425
│ ├── decorator-bindings.md ← Decorator and YAML Bindings
2526
│ ├── middleware-system.md ← Middleware System
@@ -69,6 +70,7 @@ Implementation-ready feature specifications for SDK developers. Each document de
6970
| Feature Spec | Description |
7071
|--------------|-------------|
7172
| [ACL System](./features/acl-system.md) | Pattern-based Access Control List with first-match-wins evaluation |
73+
| [Approval System](./features/approval-system.md) | Runtime enforcement of `requires_approval` via pluggable ApprovalHandler |
7274
| [Core Executor](./features/core-executor.md) | Central orchestration engine with 10-step execution pipeline |
7375
| [Decorator & YAML Bindings](./features/decorator-bindings.md) | `@module` decorator and YAML-based declarative module creation |
7476
| [Middleware System](./features/middleware-system.md) | Composable middleware pipeline with onion execution model |
@@ -126,4 +128,5 @@ Quickly find authoritative definitions for concepts:
126128
| Registry | [registry-api.md](./api/registry-api.md) | [README](../README.md#quick-start) |
127129
| Executor | [executor-api.md](./api/executor-api.md) | [README](../README.md#quick-start) |
128130
| ACL | [PROTOCOL_SPEC.md §6](../PROTOCOL_SPEC.md#6-acl-specification) | [README](../README.md#acl-access-control) |
131+
| ApprovalHandler | [approval-system.md](./features/approval-system.md) | [PROTOCOL_SPEC.md §7](../PROTOCOL_SPEC.md#7-approval-system) |
129132
| Middleware | [middleware.md](./guides/middleware.md) | [README](../README.md#middleware) |

docs/api/executor-api.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ class Executor:
1818
self,
1919
registry: Registry,
2020
middlewares: list[Middleware] | None = None,
21-
acl: "ACL | None" = None
21+
acl: "ACL | None" = None,
22+
approval_handler: "ApprovalHandler | None" = None
2223
) -> None:
2324
"""
2425
Initialize Executor
@@ -27,6 +28,7 @@ class Executor:
2728
registry: Module registry
2829
middlewares: Middleware list (executed in order)
2930
acl: Access Control List
31+
approval_handler: Approval handler for modules with requires_approval=true
3032
"""
3133
...
3234

@@ -53,6 +55,8 @@ class Executor:
5355
ModuleNotFoundError: Module does not exist
5456
ValidationError: Input/output validation failed
5557
ACLDeniedError: Insufficient permissions
58+
ApprovalDeniedError: Approval explicitly rejected
59+
ApprovalTimeoutError: Approval timed out
5660
CallDepthExceededError: Call chain depth exceeded
5761
CircularCallError: Circular call detected
5862
CallFrequencyExceededError: Same module call frequency exceeded
@@ -170,6 +174,29 @@ acl = ACL.load("./acl/global_acl.yaml")
170174
executor = Executor(registry=registry, acl=acl)
171175
```
172176

177+
### 2.4 With ApprovalHandler
178+
179+
```python
180+
from apcore import Registry, Executor
181+
from apcore.approval import AutoApproveHandler, CallbackApprovalHandler
182+
183+
# Auto-approve (testing/development)
184+
executor = Executor(registry=registry, approval_handler=AutoApproveHandler())
185+
186+
# Custom callback
187+
async def my_approval_logic(request):
188+
if request.module_id.startswith("dangerous."):
189+
return ApprovalResult(status="denied", reason="Dangerous modules blocked")
190+
return ApprovalResult(status="approved")
191+
192+
executor = Executor(
193+
registry=registry,
194+
approval_handler=CallbackApprovalHandler(my_approval_logic)
195+
)
196+
```
197+
198+
When an `approval_handler` is set, modules declaring `requires_approval=true` in their annotations will trigger the handler at Step 4.5 of the execution pipeline. See [Approval System](../features/approval-system.md).
199+
173200
---
174201

175202
## 3. Calling Modules
@@ -300,6 +327,8 @@ from apcore import (
300327
ModuleNotFoundError,
301328
ValidationError,
302329
ACLDeniedError,
330+
ApprovalDeniedError,
331+
ApprovalTimeoutError,
303332
CallDepthExceededError,
304333
CircularCallError,
305334
CallFrequencyExceededError,
@@ -325,6 +354,14 @@ except ACLDeniedError as e:
325354
print(f"Required: {e.required_permission}")
326355
print(f"Caller: {e.caller_id}")
327356

357+
except ApprovalDeniedError as e:
358+
# Approval explicitly rejected (requires_approval=true module)
359+
print(f"Approval denied: {e.reason}")
360+
361+
except ApprovalTimeoutError as e:
362+
# Approval timed out waiting for response
363+
print(f"Approval timed out: {e.reason}")
364+
328365
except CallDepthExceededError as e:
329366
# Call chain depth exceeded
330367
print(f"Call depth exceeded: {e.current_depth}/{e.max_depth}")
@@ -396,6 +433,12 @@ executor.call(module_id, inputs, context)
396433
│ └─ acl.check(caller_id, module_id, context)
397434
│ └─ If denied, throw ACLDeniedError
398435
436+
├─ 4.5. Approval gate (if approval_handler configured)
437+
│ └─ Only for modules with requires_approval=true
438+
│ └─ approval_handler.request_approval(request)
439+
│ └─ If denied, throw ApprovalDeniedError
440+
│ └─ If timeout, throw ApprovalTimeoutError
441+
399442
├─ 5. Input validation
400443
│ └─ Validate against input_schema
401444
│ └─ If failed, throw ValidationError
@@ -459,6 +502,12 @@ result = context.executor.call(
459502
└────┬────┘ └──────────────────┘
460503
│ permission passed
461504
505+
┌──────────┐ denied/timeout ┌──────────────────────────┐
506+
│ approval │──────────────────▶│ error: APPROVAL_DENIED │
507+
│ gate │ │ / APPROVAL_TIMEOUT │
508+
└────┬─────┘ └──────────────────────────┘
509+
│ approved (or skipped)
510+
462511
┌──────────┐ validation failed ┌──────────────────────┐
463512
│ validate │────────────────────▶│ error: VALIDATION │
464513
│ input │ └──────────────────────┘
@@ -532,7 +581,7 @@ Implementations **MUST** handle Executor edge cases per the following table:
532581
**Concurrent safety notes:**
533582
- Executor instance **MUST** be thread-safe, supporting multi-threaded concurrent calls
534583
- Each `call()` **SHOULD** use independent Context instance (created via `derive()`)
535-
- See [PROTOCOL_SPEC §11.7 Concurrency Model Specification](../../PROTOCOL_SPEC.md#117-concurrency-model-specification)
584+
- See [PROTOCOL_SPEC §12.7 Concurrency Model Specification](../../PROTOCOL_SPEC.md#127-concurrency-model-specification)
536585

537586
---
538587

docs/api/module-interface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ class ModuleAnnotations:
407407
| `readonly` | `False` | Does not modify any state | `True` → Safe to call |
408408
| `destructive` | `False` | May delete/overwrite data | `True` → Warn before calling |
409409
| `idempotent` | `False` | Repeated calls have no additional side effects | `True` → Safe to retry |
410-
| `requires_approval` | `False` | Requires human confirmation | `True` → Seek consent |
410+
| `requires_approval` | `False` | Requires human confirmation before execution. When an `ApprovalHandler` is configured on the Executor, this is enforced at runtime (Step 4.5). See [Approval System](../features/approval-system.md). | `True` → Seek consent |
411411
| `open_world` | `True` | Connects to external systems | `True` → May be slow |
412412
| `streaming` | `False` | Supports streaming chunk-by-chunk output | `True` → Read output incrementally |
413413

docs/features/approval-system.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Approval System
2+
3+
## Overview
4+
5+
The Approval System provides runtime enforcement of the `requires_approval` annotation. When a module declares `requires_approval=true` and an `ApprovalHandler` is configured on the Executor, the handler is invoked at **Step 4.5** of the execution pipeline — after ACL checks pass and before input validation begins. This allows human or automated review of sensitive operations before they execute.
6+
7+
The Approval System is architecturally separate from the ACL System. ACL answers "who is allowed to call this module?" while Approval answers "does this particular invocation need sign-off before proceeding?"
8+
9+
## Requirements
10+
11+
- Provide a pluggable `ApprovalHandler` protocol that SDK implementations can satisfy with custom logic.
12+
- Enforce the approval gate at Executor Step 4.5, after ACL (Step 4) and before Input Validation (Step 5).
13+
- Skip the approval gate entirely when no `ApprovalHandler` is configured, or when the module does not declare `requires_approval=true`.
14+
- Support synchronous approval flows (Phase A) where `request_approval()` blocks until a decision is returned.
15+
- Optionally support asynchronous approval flows (Phase B) where a `pending` status is returned with an `approval_id`, and execution resumes when the client retries with an `_approval_token`.
16+
- Raise structured errors (`APPROVAL_DENIED`, `APPROVAL_TIMEOUT`, `APPROVAL_PENDING`) that map cleanly to protocol error codes.
17+
- Ship built-in handlers for common cases: `AlwaysDenyHandler` (safe default), `AutoApproveHandler` (testing), and `CallbackApprovalHandler` (custom function).
18+
19+
## Technical Design
20+
21+
### Approval Gate (Executor Step 4.5)
22+
23+
The approval gate is inserted between ACL Enforcement (Step 4) and Input Validation (Step 5) in the Executor's pipeline. The algorithm:
24+
25+
1. Check if `approval_handler` is configured on the Executor.
26+
2. If not configured, skip to Step 5.
27+
3. Check if the target module declares `requires_approval=true` in its annotations.
28+
4. If not, skip to Step 5.
29+
5. Build an `ApprovalRequest` containing `module_id`, `arguments`, `caller` identity, and the module's `annotations`.
30+
6. Call `approval_handler.request_approval(request)`.
31+
7. If `approved` → proceed to Step 5.
32+
8. If `denied` → raise `ApprovalDeniedError` with the handler's reason.
33+
9. If `timeout` → raise `ApprovalTimeoutError`.
34+
10. If `pending` (Phase B only) → raise `ApprovalPendingError` with `approval_id`.
35+
36+
### ApprovalHandler Protocol
37+
38+
```python
39+
class ApprovalHandler(Protocol):
40+
async def request_approval(self, request: ApprovalRequest) -> ApprovalResult:
41+
"""Request approval for a module invocation. Returns the decision."""
42+
...
43+
```
44+
45+
Implementations receive an `ApprovalRequest` and return an `ApprovalResult`. The handler may block (waiting for human input via UI, Slack, etc.) or return immediately (auto-approve for testing).
46+
47+
### Data Types
48+
49+
**ApprovalRequest** carries the invocation context to the handler:
50+
51+
| Field | Type | Description |
52+
|-------|------|-------------|
53+
| `module_id` | `str` | Canonical module ID |
54+
| `arguments` | `dict` | Input arguments for the call |
55+
| `caller` | `Identity \| None` | Caller identity from context |
56+
| `annotations` | `ModuleAnnotations` | Full annotation set of the module |
57+
58+
**ApprovalResult** carries the handler's decision:
59+
60+
| Field | Type | Description |
61+
|-------|------|-------------|
62+
| `status` | `str` | One of: `approved`, `denied`, `timeout`, `pending` |
63+
| `reason` | `str \| None` | Human-readable explanation |
64+
| `approval_id` | `str \| None` | Phase B: token for async resume |
65+
66+
### Error Types
67+
68+
| Error | Code | HTTP | When |
69+
|-------|------|------|------|
70+
| `ApprovalDeniedError` | `APPROVAL_DENIED` | 403 | Handler returns `denied` |
71+
| `ApprovalTimeoutError` | `APPROVAL_TIMEOUT` | 408 | Handler returns `timeout` |
72+
| `ApprovalPendingError` | `APPROVAL_PENDING` | 202 | Handler returns `pending` (Phase B) |
73+
74+
All three extend the base `ApprovalError`, which extends `ModuleError`.
75+
76+
### Built-in Handlers
77+
78+
| Handler | Behavior | Use Case |
79+
|---------|----------|----------|
80+
| `AlwaysDenyHandler` | Always returns `denied` | Safe default when approval is required but no handler is configured |
81+
| `AutoApproveHandler` | Always returns `approved` | Testing and development |
82+
| `CallbackApprovalHandler` | Delegates to a user-provided callback function | Custom approval logic |
83+
84+
### Protocol Bridge Handlers
85+
86+
Protocol bridges (apcore-mcp, apcore-a2a) provide their own `ApprovalHandler` implementations that use protocol-native mechanisms:
87+
88+
- **`ElicitationApprovalHandler`** (apcore-mcp) — Uses the MCP elicitation protocol to present an approval prompt to the AI client, which relays it to the human user.
89+
- **`A2AApprovalHandler`** (apcore-a2a, future) — Uses the A2A interaction protocol.
90+
- **`WebhookApprovalHandler`** — Sends approval requests to an HTTP endpoint and waits for a callback.
91+
92+
### Phased Implementation
93+
94+
| Phase | Scope | Requirement |
95+
|-------|-------|-------------|
96+
| **Phase A** | Synchronous approval: handler blocks until decision | **MUST** implement for conformance |
97+
| **Phase B** | Asynchronous approval: `pending` + `approval_id` + retry with `_approval_token` | **MAY** implement |
98+
99+
## Key Files
100+
101+
| File | Purpose |
102+
|------|---------|
103+
| `approval.py` | `ApprovalHandler` protocol, `ApprovalRequest`, `ApprovalResult`, built-in handlers |
104+
| `errors.py` | `ApprovalError`, `ApprovalDeniedError`, `ApprovalTimeoutError`, `ApprovalPendingError` |
105+
| `executor.py` | Step 4.5 integration in `call()`, `call_async()`, `stream()` |
106+
107+
## Dependencies
108+
109+
### Internal
110+
- **Executor** — The approval gate is embedded in the Executor pipeline at Step 4.5.
111+
- **Module Annotations** — The `requires_approval` field on `ModuleAnnotations` triggers the gate.
112+
- **Context / Identity** — Caller identity is passed to the handler for access-aware approval decisions.
113+
114+
## Testing Strategy
115+
116+
- **Unit tests** verify that the approval gate fires only when both an `ApprovalHandler` is configured and the module declares `requires_approval=true`.
117+
- **Handler tests** confirm each built-in handler returns the expected `ApprovalResult` status.
118+
- **Error mapping tests** verify that `denied``ApprovalDeniedError`, `timeout``ApprovalTimeoutError`, `pending``ApprovalPendingError`.
119+
- **Skip tests** confirm the gate is skipped when no handler is set, or when the module does not require approval.
120+
- **Integration tests** run a full pipeline execution with approval handlers to verify end-to-end behavior including error propagation to callers.
121+
- Test naming follows the `test_<unit>_<behavior>` convention.

docs/features/core-executor.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ The executor processes every module call through the following pipeline:
3131

3232
4. **ACL Enforcement** -- The caller's `Identity` (extracted from the context) is checked against the module's access control list. Unauthorized calls are rejected before any execution occurs.
3333

34+
4.5. **Approval Gate** -- If an `ApprovalHandler` is configured and the module declares `requires_approval=true`, the handler is invoked to obtain approval before proceeding. The handler may block for human input or return immediately. Denied or timed-out approvals raise `ApprovalDeniedError` or `ApprovalTimeoutError`. Skipped entirely when no handler is configured or the module does not require approval. See [Approval System](./approval-system.md).
35+
3436
5. **Input Validation with Pydantic + Sensitive Field Redaction** -- The call's input payload is validated against the module's input schema (a dynamically generated Pydantic model). Fields annotated with `x-sensitive` are redacted from logs and error messages using the `redact_sensitive` utility.
3537

3638
6. **Middleware Before Chain** -- All registered "before" middleware functions are executed in order. Each middleware receives the context and validated input, and may modify or enrich them before the module runs.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ nav:
6868
- Executor API: api/executor-api.md
6969
- Feature Specifications:
7070
- ACL System: features/acl-system.md
71+
- Approval System: features/approval-system.md
7172
- Core Executor: features/core-executor.md
7273
- Decorator & YAML Bindings: features/decorator-bindings.md
7374
- Middleware System: features/middleware-system.md

0 commit comments

Comments
 (0)