You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
-**`priority`**: Lower numbers execute first (default: 50)
@@ -75,6 +75,7 @@ The hook system is backed by a lightweight plugin framework built as a Mellea de
75
75
- Exposes `PluginSet`for grouping related hooks/plugins into composable, reusable units
76
76
- Exposes `register()`forglobal plugin registration and`block()`as a convenience for returning blocking `PluginResult`s
77
77
- Implements a plugin manager that loads, registers, and governs the execution of plugins
78
+
- Enforces per-hook-type payload policies via `HookPayloadPolicy`, accepting only writable-field changes from plugins
78
79
79
80
The public API surface:
80
81
@@ -108,27 +109,31 @@ result = await plugin_manager.invoke_hook(hook_type, payload, context)
108
109
The caller (the base class method) is responsible for both invoking the hook and processing the result. Processing means checking the result for one of three possible outcomes:
109
110
110
111
1. **Continue with original payload**: — `PluginResult(continue_processing=True)`with no `modified_payload`. The caller proceeds unchanged.
111
-
2. **Continue with modified payload**: — `PluginResult(continue_processing=True, modified_payload=...)`. The caller uses the modified payload fields in place of the originals.
112
+
2. **Continue with modified payload**: — `PluginResult(continue_processing=True, modified_payload=...)`. The plugin manager applies the hook's payload policy, accepting only changes to writable fields and discarding unauthorized modifications. The caller uses the policy-filtered payload in place of the original.
112
113
3. **Block execution** — `PluginResult(continue_processing=False, violation=...)`. The caller raises or returns early with structured error information.
113
114
114
115
Hooks cannot redirect control flow, jump to arbitrary code, or alter the calling method's logic beyond these outcomes. This is enforced by the `PluginResult` type.
115
116
116
117
### Payload Design Principles
117
118
118
-
Hook payloads follow five design principles:
119
+
Hook payloads follow six design principles:
119
120
120
121
1. **Strongly typed** — Each hook has a dedicated payload dataclass (not a generic dict). This enables IDE autocompletion, static analysis, and clear documentation of what each hook receives.
121
122
2. **Sufficient (maximize-at-boundary)** — Each payload includes everything available at that point in time. Post-hooks include the pre-hook fields plus results. This avoids forcing plugins to maintain their own state across pre/post pairs.
122
-
3. **Immutable context** — `PluginContext` fields are read-only; only the `payload`is mutable. This separates "what the plugin can observe"from"what the plugin can change."
123
-
4. **Serializable** — Payloads should be serializable for external (MCP-based) plugins that run out-of-process. All payload fields use types that can round-trip through JSONor similar formats.
124
-
5. **Versioned** — Payload schemas carry a `payload_version` so plugins can detect incompatible changes at registration time rather than at runtime.
123
+
3. **Frozen (immutable)** — Payloads are frozen Pydantic models (`model_config = ConfigDict(frozen=True)`). Plugins cannot mutate payload attributes in place. To propose changes, plugins must call `payload.model_copy(update={...})`andreturn the copy via `PluginResult.modified_payload`. This ensures every modification is explicit and flows through the policy system.
124
+
4. **Policy-controlled** — Each hook type declares a `HookPayloadPolicy` specifying which fields are writable. The plugin manager applies the policy after each plugin returns, accepting only changes to writable fields and silently discarding unauthorized modifications. This separates "what the plugin can observe"from"what the plugin can change" — and enforces it at the framework level. See [Hook Payload Policies](#hook-payload-policies) for the full policy table.
125
+
5. **Serializable** — Payloads should be serializable for external (MCP-based) plugins that run out-of-process. All payload fields use types that can round-trip through JSONor similar formats.
126
+
6. **Versioned** — Payload schemas carry a `payload_version` so plugins can detect incompatible changes at registration time rather than at runtime.
125
127
126
128
## 2. Common Payload Fields
127
129
128
130
All hook payloads inherit these base fields:
129
131
130
132
```python
131
133
class BasePayload(PluginPayload):
134
+
"""Frozen base — all payloads are immutable by design."""
session_id: str|None=None# Session identifier (None for functional API calls)
133
138
request_id: str# Unique ID for this execution chain
134
139
timestamp: datetime # When the event fired
@@ -168,6 +173,131 @@ class BasePayload(PluginPayload):
168
173
|`context_prune`| Context Operations | Context | When context is trimmed |
169
174
|`error_occurred`| Error Handling | Cross-cutting | When an unrecoverable error occurs |
170
175
176
+
## 3b. Hook Payload Policies
177
+
178
+
Each hook type declares a `HookPayloadPolicy` that specifies which payload fields plugins are allowed to modify. The plugin manager enforces these policies after each plugin returns: only changes to writable fields are accepted; all other modifications are silently discarded.
179
+
180
+
Hooks not listed in the policy table are **observe-only** — plugins can read the payload but cannot modify any fields.
181
+
182
+
### Policy Types
183
+
184
+
```python
185
+
from dataclasses import dataclass
186
+
from enum import Enum
187
+
188
+
class DefaultHookPolicy(str, Enum):
189
+
"""Controls behavior for hooks without an explicit policy."""
190
+
ALLOW="allow"# Accept all modifications (backwards-compatible)
191
+
DENY="deny"# Reject all modifications (strict mode, default for Mellea)
192
+
193
+
@dataclass(frozen=True)
194
+
class HookPayloadPolicy:
195
+
"""Defines which payload fields plugins may modify."""
196
+
writable_fields: frozenset[str]
197
+
```
198
+
199
+
### Policy Enforcement
200
+
201
+
When a plugin returns `PluginResult(modified_payload=...)`, the plugin manager applies `apply_policy()`:
202
+
203
+
```python
204
+
def apply_policy(
205
+
original: BaseModel,
206
+
modified: BaseModel,
207
+
policy: HookPayloadPolicy,
208
+
) -> BaseModel |None:
209
+
"""Accept only changes to writable fields; discard all others.
210
+
211
+
Returns an updated payload via model_copy(update=...), or None
212
+
if the plugin made no effective (allowed) changes.
213
+
"""
214
+
updates: dict[str, Any] = {}
215
+
for field in policy.writable_fields:
216
+
old_val=getattr(original, field, _SENTINEL)
217
+
new_val=getattr(modified, field, _SENTINEL)
218
+
if new_val isnot_SENTINELand new_val != old_val:
219
+
updates[field] = new_val
220
+
return original.model_copy(update=updates) if updates elseNone
Mellea uses `DefaultHookPolicy.DENY`as the default for hooks without an explicit policy. This means:
267
+
268
+
-**Hooks with an explicit policy**: Only writable fields are accepted; other changes are discarded.
269
+
-**Hooks without a policy** (observe-only): All modifications are rejected with a warning log.
270
+
-**Custom hooks**: Custom hooks registered by users default to `DENY`. To allow modifications, pass a `HookPayloadPolicy` when registering the custom hook type.
271
+
272
+
### Modification Pattern
273
+
274
+
Because payloads are frozen, plugins must use `model_copy(update={...})` to create a modified copy:
Attempting to set attributes directly (e.g., `payload.model_options = {...}`) raises a `FrozenModelError`.
288
+
289
+
### Chaining
290
+
291
+
When multiple plugins modify the same hook's payload, modifications are chained:
292
+
293
+
1. Plugin A receives the original payload, returns a modified copy.
294
+
2. The policy filters Plugin A's changes to writable fields only.
295
+
3. Plugin B receives the policy-filtered result from Plugin A.
296
+
4. The policy filters Plugin B's changes.
297
+
5. The final policy-filtered payload is returned to the caller.
298
+
299
+
This ensures each plugin sees the cumulative effect of prior plugins, andall modifications pass through the policy filter.
300
+
171
301
## 4. Hook Definitions
172
302
173
303
### A. Session Lifecycle Hooks
@@ -1039,20 +1169,26 @@ class ContextSnapshot:
1039
1169
Hooks can return different result types to control execution:
1040
1170
1041
1171
1. **Continue (no-op)** — `PluginResult(continue_processing=True)`with no `modified_payload`. Execution proceeds with the original payload unchanged.
1042
-
2. **Continue with modification** — `PluginResult(continue_processing=True, modified_payload=...)`. Execution proceeds withthe modified payload fieldsin place of the originals.
1172
+
2. **Continue with modification** — `PluginResult(continue_processing=True, modified_payload=...)`. The plugin manager applies the hook's `HookPayloadPolicy`, accepting only changes to writable fields. Execution proceeds with the policy-filtered payload.
1043
1173
3. **Block execution** — `PluginResult(continue_processing=False, violation=...)`. Execution halts with structured error information via `PluginViolation`.
1044
1174
1045
1175
These three outcomes are exhaustive. Hooks cannot redirect control flow, throw arbitrary exceptions, or alter the calling method's logic beyond these outcomes. This is enforced by the `PluginResult` type — there is no escape hatch. The `violation` field provides structured error information but does not influence which code path runs next.
1046
1176
1177
+
Because payloads are frozen, the `modified_payload`in option 2 must be a new object created via `payload.model_copy(update={...})` — not a mutated version of the original.
1178
+
1047
1179
### Modify Payload
1048
1180
1049
1181
```python
1182
+
# Create an immutable copy with only the desired changes
>**Note**: Only changes to fields listed in the hook's `HookPayloadPolicy.writable_fields` will be accepted. Changes to other fields are silently discarded by the policy enforcement layer.
0 commit comments