microcoreos 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microcoreos/__init__.py +23 -0
- microcoreos/_template/.agent/skills/microcoreos-architecture/SKILL.md +24 -0
- microcoreos/_template/.agent/skills/microcoreos-architecture/agent.md +18 -0
- microcoreos/_template/.agent/workflows/feature-plan.md +111 -0
- microcoreos/_template/.agent/workflows/multi-domain-plan.md +96 -0
- microcoreos/_template/.agent/workflows/new-domain.md +201 -0
- microcoreos/_template/.agent/workflows/new-tool.md +72 -0
- microcoreos/_template/.dockerignore +23 -0
- microcoreos/_template/.env.example +96 -0
- microcoreos/_template/.gitignore +57 -0
- microcoreos/_template/AGENTS.md +175 -0
- microcoreos/_template/Dockerfile +35 -0
- microcoreos/_template/INSTRUCTIONS_FOR_AI.md +560 -0
- microcoreos/_template/dev_infra/cache_probe.py +176 -0
- microcoreos/_template/dev_infra/docker-compose.yml +137 -0
- microcoreos/_template/docs/CLI.md +187 -0
- microcoreos/_template/docs/CORE_INFRASTRUCTURE.md +372 -0
- microcoreos/_template/docs/ELASTIC_DEPLOYMENT.md +277 -0
- microcoreos/_template/docs/EVENT_BUS.md +288 -0
- microcoreos/_template/docs/HTTP_SERVER.md +98 -0
- microcoreos/_template/docs/INDEX.md +41 -0
- microcoreos/_template/docs/OBSERVABILITY.md +56 -0
- microcoreos/_template/docs/OBSERVABILITY_API.md +183 -0
- microcoreos/_template/docs/PARALLEL_DEVELOPMENT.md +494 -0
- microcoreos/_template/docs/PLAN_EVENT_LINTER.md +95 -0
- microcoreos/_template/docs/TECH_DEBT.md +388 -0
- microcoreos/_template/docs/translations/es/README.md +265 -0
- microcoreos/_template/domains/devtools/lint/plugin_sources.py +51 -0
- microcoreos/_template/domains/devtools/plugins/discovery_naming_linter_plugin.py +89 -0
- microcoreos/_template/domains/devtools/plugins/domain_isolation_linter_plugin.py +80 -0
- microcoreos/_template/domains/devtools/plugins/event_contract_linter_plugin.py +420 -0
- microcoreos/_template/domains/devtools/plugins/event_schemas_plugin.py +94 -0
- microcoreos/_template/domains/devtools/plugins/field_divergence_linter_plugin.py +178 -0
- microcoreos/_template/domains/devtools/plugins/plan_validator_plugin.py +581 -0
- microcoreos/_template/domains/devtools/plugins/route_collision_linter_plugin.py +51 -0
- microcoreos/_template/domains/devtools/plugins/table_ownership_linter_plugin.py +66 -0
- microcoreos/_template/domains/devtools/plugins/tool_doc_drift_linter_plugin.py +74 -0
- microcoreos/_template/domains/system/plugins/event_delivery_monitor_plugin.py +53 -0
- microcoreos/_template/domains/system/plugins/system_events_plugin.py +132 -0
- microcoreos/_template/domains/system/plugins/system_events_stream_plugin.py +46 -0
- microcoreos/_template/domains/system/plugins/system_logs_stream_plugin.py +47 -0
- microcoreos/_template/domains/system/plugins/system_metrics_plugin.py +85 -0
- microcoreos/_template/domains/system/plugins/system_status_plugin.py +66 -0
- microcoreos/_template/domains/system/plugins/system_traces_plugin.py +128 -0
- microcoreos/_template/domains/system/plugins/system_traces_stream_plugin.py +121 -0
- microcoreos/_template/domains/system/plugins/tool_health_plugin.py +53 -0
- microcoreos/_template/extras/available_domains/chaos/plugins/blocking_boot_plugin.py +19 -0
- microcoreos/_template/extras/available_domains/chaos/plugins/chaos_control_plugin.py +625 -0
- microcoreos/_template/extras/available_domains/chaos/plugins/failing_plugin.py +27 -0
- microcoreos/_template/extras/available_domains/chaos/plugins/stress_plugin.py +40 -0
- microcoreos/_template/extras/available_domains/ping/plugins/ping_plugin.py +36 -0
- microcoreos/_template/extras/available_domains/scheduler/migrations/001_scheduler_one_shots.sql +8 -0
- microcoreos/_template/extras/available_domains/scheduler/models/scheduler_one_shot.py +15 -0
- microcoreos/_template/extras/available_domains/scheduler/plugins/durable_one_shots_plugin.py +125 -0
- microcoreos/_template/extras/available_domains/users/migrations/001_create_users.sql +12 -0
- microcoreos/_template/extras/available_domains/users/models/user.py +31 -0
- microcoreos/_template/extras/available_domains/users/plugins/create_user_plugin.py +89 -0
- microcoreos/_template/extras/available_domains/users/plugins/get_me_plugin.py +63 -0
- microcoreos/_template/extras/available_domains/users/plugins/login_plugin.py +107 -0
- microcoreos/_template/extras/available_domains/users/plugins/logout_plugin.py +26 -0
- microcoreos/_template/extras/available_tools/auth/auth_tool.py +132 -0
- microcoreos/_template/extras/available_tools/chaos/chaos_tool.py +32 -0
- microcoreos/_template/extras/available_tools/kafka/kafka_driver.py +452 -0
- microcoreos/_template/extras/available_tools/postgresql/postgresql_tool.py +882 -0
- microcoreos/_template/extras/available_tools/rabbitmq/rabbitmq_driver.py +303 -0
- microcoreos/_template/extras/available_tools/redis_state/redis_state_tool.py +253 -0
- microcoreos/_template/extras/available_tools/s3/__init__.py +0 -0
- microcoreos/_template/extras/available_tools/s3/s3_tool.py +426 -0
- microcoreos/_template/extras/available_tools/scheduler/scheduler_tool.py +328 -0
- microcoreos/_template/main.py +9 -0
- microcoreos/_template/plans/PILOT.md +99 -0
- microcoreos/_template/plans/README.md +236 -0
- microcoreos/_template/plans/active_plan.md +25 -0
- microcoreos/_template/plans/active_plan.yaml +43 -0
- microcoreos/_template/tools/config/config_tool.py +110 -0
- microcoreos/_template/tools/context/authoring_guide.md +305 -0
- microcoreos/_template/tools/context/context_tool.py +161 -0
- microcoreos/_template/tools/context/renderers.py +118 -0
- microcoreos/_template/tools/context/scanners.py +267 -0
- microcoreos/_template/tools/event_bus/drivers.py +108 -0
- microcoreos/_template/tools/event_bus/envelope.py +55 -0
- microcoreos/_template/tools/event_bus/event_bus_tool.py +506 -0
- microcoreos/_template/tools/event_bus/redis_streams_driver.py +300 -0
- microcoreos/_template/tools/event_bus/sqlite_driver.py +316 -0
- microcoreos/_template/tools/http_server/context.py +120 -0
- microcoreos/_template/tools/http_server/http_server_tool.py +558 -0
- microcoreos/_template/tools/http_server/pipeline.py +267 -0
- microcoreos/_template/tools/logger/logger_tool.py +96 -0
- microcoreos/_template/tools/sqlite/errors.py +152 -0
- microcoreos/_template/tools/sqlite/migrations.py +140 -0
- microcoreos/_template/tools/sqlite/sqlite_tool.py +591 -0
- microcoreos/_template/tools/sqlite/transaction.py +189 -0
- microcoreos/_template/tools/state/state_tool.py +173 -0
- microcoreos/_template/tools/system/registry_tool.py +90 -0
- microcoreos/_template/tools/telemetry/__init__.py +0 -0
- microcoreos/_template/tools/telemetry/telemetry_tool.py +143 -0
- microcoreos/base_plugin.py +20 -0
- microcoreos/base_tool.py +48 -0
- microcoreos/catalog.py +268 -0
- microcoreos/cli.py +206 -0
- microcoreos/container.py +222 -0
- microcoreos/context.py +6 -0
- microcoreos/kernel.py +223 -0
- microcoreos/project_readme.md +107 -0
- microcoreos/registry.py +53 -0
- microcoreos/scaffold.py +218 -0
- microcoreos/upgrade.py +547 -0
- microcoreos-0.1.0.dist-info/METADATA +366 -0
- microcoreos-0.1.0.dist-info/RECORD +112 -0
- microcoreos-0.1.0.dist-info/WHEEL +4 -0
- microcoreos-0.1.0.dist-info/entry_points.txt +2 -0
- microcoreos-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
# 🤖 AI Agent Implementation Guide — Advanced Reference
|
|
2
|
+
|
|
3
|
+
> **For building plugins, read `AI_CONTEXT.md` only.** This file is for advanced topics: testing, observability, creating tools, and edge cases.
|
|
4
|
+
|
|
5
|
+
## ❌ Anti-Patterns (Common AI Mistakes)
|
|
6
|
+
|
|
7
|
+
These are the most frequent errors. Check these first before writing any code.
|
|
8
|
+
|
|
9
|
+
| Wrong | Correct | Why |
|
|
10
|
+
|-------|---------|-----|
|
|
11
|
+
| `async def on_event(self, data):` | `async def on_event(self, event: EventEnvelope):` | Subscribers now receive the full envelope, not raw data |
|
|
12
|
+
| `from domains.users.models.user import UserEntity` inside `orders` plugin | Define `OrderData` inline | No cross-domain imports |
|
|
13
|
+
| `class CreateUserRequest(BaseModel): name: str` (bare field) | `name: str = Field(min_length=1)` | FastAPI won't validate without constraints |
|
|
14
|
+
| `add_endpoint("/users", "POST", self.execute, ...)` without `response_model=` | Always pass `response_model=CreateUserResponse` | No OpenAPI docs generated |
|
|
15
|
+
| Logic inside `__init__` | Move to `on_boot()` or `execute()` | `__init__` is DI only |
|
|
16
|
+
| `import asyncio; asyncio.run(...)` inside a plugin | `await` or schedule via `scheduler` | Already inside an event loop |
|
|
17
|
+
| `?` placeholders in SQL | `$1, $2, $3...` | PostgreSQL-style; SQLite converts automatically |
|
|
18
|
+
| Returning the full Entity object (including `password_hash`) | Define a response schema with only the fields you expose | Leaks sensitive data |
|
|
19
|
+
| `from tools.http_server.http_server_tool import HttpServerTool` | Use DI: `def __init__(self, http)` | Hardcoded imports break tool swapping |
|
|
20
|
+
| Expecting automatic Kernel retries | Handle your own exceptions or use Tool-level resilience | **ToolProxy NO LONGER retries automatically** to prevent duplicates |
|
|
21
|
+
| `return {"success": False, "error": str(e)}` | `logger.error(e); return {"success": False, "error": "Generic Message"}` | `str(e)` leaks table names, paths, and internal logic |
|
|
22
|
+
| `if "UNIQUE" in str(e):` (deciding by the error's text) | `if getattr(e, "kind", None) == "unique_violation":` | Each engine words its errors differently; the db tool classifies them into a closed vocabulary so the branch survives a tool swap |
|
|
23
|
+
| `bus.publish("x.y", {"id": 1})` (raw dict) | Define `XyPayload(BaseModel)` and publish `XyPayload(id=1).model_dump()` | The publisher owns the event contract; raw dicts are flagged `UNTYPED_PAYLOAD` by `/system/lint` |
|
|
24
|
+
| `await asyncio.sleep(0.1)` in a test, then asserting async delivery | `await wait_until(lambda: <condition>)` from `tests/helpers/async_wait.py` | A fixed sleep guesses a duration and flakes under CI CPU contention; polling passes as soon as delivery lands |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚠️ CRITICAL RULES (DO NOT IGNORE)
|
|
29
|
+
|
|
30
|
+
1. **`main.py` is sacred** — Never modify. It only boots the Kernel.
|
|
31
|
+
2. **Event Contract** — Subscribers receive `EventEnvelope`. Access data via `event.payload`.
|
|
32
|
+
3. **No Hidden Magic** — The Kernel does NOT retry failed tool calls. Idempotency is your responsibility.
|
|
33
|
+
4. **Safe Errors** — NEVER return `str(e)` to the client. Return safe, generic messages only.
|
|
34
|
+
5. **Event Bus Power** — Leverage `ttl`, `retries`, and `backoff` in `subscribe()` and `publish()`.
|
|
35
|
+
6. **DLQ Monitoring** — Final failures go to `_dlq.<event>`. Subscribe to it for error handling.
|
|
36
|
+
7. **No framework patterns** — No Routers, Controllers, or Services. Only Tools (infrastructure) and Plugins (business logic).
|
|
37
|
+
8. **No cross-domain imports** — Domains communicate exclusively via `event_bus`.
|
|
38
|
+
9. **CSRF Guard** — HTTP mutations (POST/PUT/DELETE) via cookie auth REQUIRE `X-Requested-With` header.
|
|
39
|
+
10. **Secure Cookies** — `context.set_cookie` defaults to `Secure=True`, `HttpOnly=True`, `SameSite=Lax`.
|
|
40
|
+
11. **Return format**: Always `{"success": bool, "data": ..., "error": ...}`.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 🧭 Navigation
|
|
45
|
+
|
|
46
|
+
| Task | Section |
|
|
47
|
+
|---|---|
|
|
48
|
+
| New feature on existing domain | [Plugin](#-new-plugin) |
|
|
49
|
+
| New functional area from scratch | [Domain](#-new-domain) |
|
|
50
|
+
| New infrastructure capability | [Tool](#-new-tool) |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 🧩 New Domain
|
|
55
|
+
|
|
56
|
+
Folder structure:
|
|
57
|
+
```
|
|
58
|
+
domains/{name}/
|
|
59
|
+
__init__.py
|
|
60
|
+
models/{name}.py ← DB Entity only (mirrors the table)
|
|
61
|
+
migrations/001_xxx.sql ← Raw SQL, auto-executed on boot
|
|
62
|
+
plugins/ ← 1 file = 1 feature
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## ⚡ New Plugin
|
|
68
|
+
|
|
69
|
+
**Location**: `domains/{domain}/plugins/{feature}_plugin.py`
|
|
70
|
+
**Rule**: 1 File = 1 Feature. Schema defined inline.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from typing import Optional
|
|
74
|
+
from pydantic import BaseModel, Field
|
|
75
|
+
from microcoreos import BasePlugin
|
|
76
|
+
|
|
77
|
+
class CreateProductPlugin(BasePlugin):
|
|
78
|
+
def __init__(self, logger, event_bus, http, db):
|
|
79
|
+
self.logger = logger
|
|
80
|
+
self.bus = event_bus
|
|
81
|
+
self.http = http
|
|
82
|
+
self.db = db
|
|
83
|
+
|
|
84
|
+
async def on_boot(self):
|
|
85
|
+
self.http.add_endpoint(
|
|
86
|
+
path="/products", method="POST",
|
|
87
|
+
handler=self.execute,
|
|
88
|
+
tags=["Products"]
|
|
89
|
+
)
|
|
90
|
+
# Leverage built-in retries for event subscribers
|
|
91
|
+
await self.bus.subscribe("order.created", self.on_order_created, retries=3, backoff=1.0)
|
|
92
|
+
|
|
93
|
+
async def execute(self, data: dict, context=None):
|
|
94
|
+
try:
|
|
95
|
+
# Action Phase
|
|
96
|
+
# publish is fire-and-forget. Use ttl for expiring messages.
|
|
97
|
+
await self.bus.publish("product.created", {"id": 123}, ttl=3600)
|
|
98
|
+
return {"success": True, "data": {"id": 123}}
|
|
99
|
+
except Exception as e:
|
|
100
|
+
# Kernel won't retry db.execute! Handle idempotency here.
|
|
101
|
+
self.logger.error(f"Failed to create product: {e}")
|
|
102
|
+
return {"success": False, "error": "Could not create product"}
|
|
103
|
+
|
|
104
|
+
async def on_order_created(self, event) -> None:
|
|
105
|
+
# event is an EventEnvelope
|
|
106
|
+
self.logger.info(f"Order received: {event.payload}")
|
|
107
|
+
# To participate in request() RPC, return a dict
|
|
108
|
+
return {"processed": True}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 📨 Event Payload Schemas (Schema Registry Readiness)
|
|
114
|
+
|
|
115
|
+
Every event a plugin publishes has a Pydantic payload model, defined **inline in
|
|
116
|
+
the publisher plugin** — same place as its request/response schemas. The
|
|
117
|
+
publisher owns the event contract, exactly like a producer registers its schema
|
|
118
|
+
in a schema registry.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# ── In the PUBLISHER plugin ─────────────────────────
|
|
122
|
+
class OrderCreatedPayload(BaseModel):
|
|
123
|
+
id: int
|
|
124
|
+
user_id: int
|
|
125
|
+
total: float
|
|
126
|
+
|
|
127
|
+
await self.bus.publish("order.created", OrderCreatedPayload(
|
|
128
|
+
id=order_id, user_id=req.user_id, total=req.total
|
|
129
|
+
).model_dump())
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# ── In a CONSUMER plugin (another domain) ───────────
|
|
134
|
+
# Never import the publisher's model (no cross-domain imports).
|
|
135
|
+
# Declare ONLY the fields this consumer needs — tolerant reader.
|
|
136
|
+
class OrderForBilling(BaseModel):
|
|
137
|
+
id: int
|
|
138
|
+
total: float
|
|
139
|
+
|
|
140
|
+
async def on_order_created(self, event):
|
|
141
|
+
order = OrderForBilling(**event.payload) # extra keys are ignored
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Rules of the pattern:
|
|
145
|
+
|
|
146
|
+
- **Publish with a bare `.model_dump()`** — no arguments. `exclude_none=True`
|
|
147
|
+
and friends can drop keys at runtime, so the linter refuses to trust them
|
|
148
|
+
(`UNKNOWN_PAYLOAD`).
|
|
149
|
+
- **Consumers are tolerant readers**: they re-declare only the fields they
|
|
150
|
+
read. This is not duplication — it is what decouples publisher evolution
|
|
151
|
+
from consumers, and the `EventContractLinterPlugin` statically cross-checks
|
|
152
|
+
that every field a consumer requires exists in every publish site.
|
|
153
|
+
- **Raw-dict publishes still work** but are flagged `UNTYPED_PAYLOAD`
|
|
154
|
+
(info, advisory) in `GET /system/lint`.
|
|
155
|
+
- **Why**: when the event bus is swapped to Kafka (Roadmap Issue 18), each
|
|
156
|
+
payload model's `model_json_schema()` is the JSON Schema the registry needs —
|
|
157
|
+
the contracts are already written, no plugin changes. `GET /system/events/schemas`
|
|
158
|
+
serves the full catalog (event → JSON Schema) today.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 🔧 New Tool
|
|
163
|
+
|
|
164
|
+
**Location**: `tools/{name}/{name}_tool.py`
|
|
165
|
+
**Rule**: Stateless, isolated, self-documented. Use `EventBusDriver` pattern for new transport layers.
|
|
166
|
+
|
|
167
|
+
### The Parity Rule (Contract over Implementation)
|
|
168
|
+
Any replacement tool (e.g., swapping SQLite for PostgreSQL, or In-Memory State
|
|
169
|
+
for Redis) MUST pass the **Parity Suite** of the reference implementation it
|
|
170
|
+
replaces. This ensures that plugins remain infrastructure-blind and behavior
|
|
171
|
+
is consistent across backends.
|
|
172
|
+
|
|
173
|
+
**Canonical examples:**
|
|
174
|
+
- `tests/tools/test_state_parity.py`: Verifies that `RedisStateTool` behaves
|
|
175
|
+
exactly like the default in-memory `StateTool`.
|
|
176
|
+
- `tests/tools/test_event_bus_broker_parity.py`: Parametrized suite that
|
|
177
|
+
runs against both the local driver and `RedisStreamsDriver`.
|
|
178
|
+
|
|
179
|
+
**Health contract (optional, only for tools with an external backend)**:
|
|
180
|
+
If the tool talks to an external service (DB, S3, broker...), make its
|
|
181
|
+
connection-error class inherit `ToolUnavailableError` so ToolProxy marks it
|
|
182
|
+
DEAD on the first infrastructure failure:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from microcoreos import BaseTool, ToolUnavailableError
|
|
186
|
+
|
|
187
|
+
class RedisError(Exception): ... # business errors
|
|
188
|
+
class RedisConnectionError(RedisError, ToolUnavailableError): ... # infra -> DEAD immediately
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
In-memory/local tools (state, logger, scheduler...) skip this entirely — the
|
|
192
|
+
fallback (DEAD after 5 consecutive failures) covers every tool automatically.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 🚦 Rate Limiting Pattern
|
|
197
|
+
|
|
198
|
+
There is **no `rate_limiter` tool, by design**. Rate limiting splits into two
|
|
199
|
+
layers, and neither needs one:
|
|
200
|
+
|
|
201
|
+
- **Volumetric / anonymous (per-IP, anti-abuse, DDoS)** → belongs to the edge
|
|
202
|
+
(nginx, gateway, CDN), never to the monolith. See the edge section in
|
|
203
|
+
`docs/ELASTIC_DEPLOYMENT.md`.
|
|
204
|
+
- **Identity-aware (per-user, per-API-key, per-plan quotas)** → business
|
|
205
|
+
policy, implemented in the plugin with the `state` tool primitive:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
async def execute(self, data: dict, context=None):
|
|
209
|
+
attempts = await self.state.increment(
|
|
210
|
+
f"login:{req.email}", namespace="rate_limit", ttl=900 # 15-min window
|
|
211
|
+
)
|
|
212
|
+
if attempts > 5:
|
|
213
|
+
context.set_status(429)
|
|
214
|
+
context.set_header("Retry-After", "900")
|
|
215
|
+
return {"success": False, "error": "Too many attempts. Try again later."}
|
|
216
|
+
# ... normal handling
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Rules of the pattern:
|
|
220
|
+
|
|
221
|
+
- **Key by identity** (user id, email, API key) — never by IP (that is the
|
|
222
|
+
edge's job).
|
|
223
|
+
- **Fixed window**: `increment()` applies the TTL only when the key is
|
|
224
|
+
created, so the counter resets `ttl` seconds after the first hit. A client
|
|
225
|
+
can burst up to 2× the limit across a window boundary — acceptable for
|
|
226
|
+
quotas and throttles. If a real case ever demands sliding-window precision,
|
|
227
|
+
that is the moment to introduce a tool, not before.
|
|
228
|
+
- **`Retry-After`**: the state contract does not expose remaining TTL, so use
|
|
229
|
+
the window size as a conservative upper bound.
|
|
230
|
+
- **Distribution is free**: with `RedisStateTool` swapped in
|
|
231
|
+
(`extras/available_tools/redis_state/`), the same code enforces the limit
|
|
232
|
+
across all replicas.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 🔐 User Roles & Authorization Pattern
|
|
237
|
+
|
|
238
|
+
Roles are **business data**, not infrastructure. The `auth` tool remains
|
|
239
|
+
infrastructure-blind (it only handles signing/verifying strings), while the
|
|
240
|
+
`users` domain manages the roles.
|
|
241
|
+
|
|
242
|
+
- **Storage**: roles are a column in the `users` table (JSON array in SQLite).
|
|
243
|
+
- **Claims**: `LoginPlugin` fetches roles and includes them in the JWT claims.
|
|
244
|
+
- **Consumption**: plugins read `data["_auth"]["roles"]` for general authorization.
|
|
245
|
+
|
|
246
|
+
### The Hybrid Authorization Rule
|
|
247
|
+
For standard operations, trust the claims in the token. For **critical
|
|
248
|
+
operations** (financial transactions, privilege escalation, destructive
|
|
249
|
+
actions), perform a **fresh check** against the database to catch late
|
|
250
|
+
revocations or role changes that happened after the token was issued:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
async def execute(self, data: dict, context=None):
|
|
254
|
+
roles = data.get("_auth", {}).get("roles", [])
|
|
255
|
+
|
|
256
|
+
# 1. Fast check (claims)
|
|
257
|
+
if "admin" not in roles:
|
|
258
|
+
return {"success": False, "error": "Forbidden"}
|
|
259
|
+
|
|
260
|
+
# 2. Fresh check (critical ops only)
|
|
261
|
+
user_id = data["_auth"]["sub"]
|
|
262
|
+
user = await self.db.query_one("SELECT roles FROM users WHERE id = $1", [user_id])
|
|
263
|
+
fresh_roles = json.loads(user["roles"])
|
|
264
|
+
if "admin" not in fresh_roles:
|
|
265
|
+
return {"success": False, "error": "Privileges revoked"}
|
|
266
|
+
|
|
267
|
+
# ... proceed
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 🧪 Testing
|
|
273
|
+
|
|
274
|
+
Constructor injection makes testing straightforward. In this project, we support and encourage two levels of testing:
|
|
275
|
+
|
|
276
|
+
### Which level? One rule
|
|
277
|
+
|
|
278
|
+
**Mock the input and the output. Use the real tool the moment you assert persistence.**
|
|
279
|
+
|
|
280
|
+
| What the test asserts | Level | Why |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| Returned dict, branch logic, error paths | **Mock** (level 2) | Nothing durable is being claimed; a real DB adds setup and no confidence |
|
|
283
|
+
| Rows in a table, state after a swap, an event a consumer actually received | **Real tool** (level 1) | The claim IS the persisted state — see below |
|
|
284
|
+
|
|
285
|
+
The reason the second row is not negotiable: a mocked `db` lets you assert
|
|
286
|
+
*"`execute` was called with this SQL string"*. That verifies the plugin talks
|
|
287
|
+
to a contract; it does **not** verify the SQL works against the schema. A
|
|
288
|
+
typo'd column, a `NOT NULL` the migration declares and the INSERT never fills,
|
|
289
|
+
a type SQLite accepts and PostgreSQL rejects — every one of those passes a
|
|
290
|
+
mocked test and fails in production. Once you assert on a table, you need the
|
|
291
|
+
table.
|
|
292
|
+
|
|
293
|
+
A plugin that both validates and writes is not a dilemma: test the validation
|
|
294
|
+
branches with mocks and the write with the real tool. They are different tests.
|
|
295
|
+
|
|
296
|
+
**Most plugin tests here are mocked, and that is correct** — the majority of
|
|
297
|
+
plugins are input → output. Reach for level 1 when the subject is the durable
|
|
298
|
+
effect, not by default.
|
|
299
|
+
|
|
300
|
+
**What mocks cannot catch, and what covers it.** A mocked test verifies the
|
|
301
|
+
plugin against a contract the test itself declares, so if a tool's real API
|
|
302
|
+
drifts the mock keeps passing. That gap is closed elsewhere, not by your test:
|
|
303
|
+
the `ToolDocDriftLinter` (boot + `/system/lint`) compares each tool's
|
|
304
|
+
documented interface against its implementation, and the parity suites
|
|
305
|
+
(`tests/tools/test_state_parity.py`,
|
|
306
|
+
`tests/tools/test_event_bus_broker_parity.py`) hold swappable tools to one
|
|
307
|
+
behaviour. Do not re-verify that in a plugin test.
|
|
308
|
+
|
|
309
|
+
### Ask for tools the way the plugin does
|
|
310
|
+
|
|
311
|
+
A plugin never builds a tool: it names one in `__init__` and the Kernel hands
|
|
312
|
+
it over. pytest injects by parameter name too, so **a test uses the same
|
|
313
|
+
vocabulary** — `tests/conftest.py` publishes one fixture per Kernel injection
|
|
314
|
+
key (`db`, `event_bus`, `auth`, `state`, `logger`, `config`). No tool imports,
|
|
315
|
+
no setup/teardown, no hand-built schema:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
@pytest.mark.migrations("users") # the domain's REAL migrations
|
|
319
|
+
async def test_create_user_persists(db, event_bus, auth, logger):
|
|
320
|
+
plugin = CreateUserPlugin(http=MagicMock(), db=db,
|
|
321
|
+
event_bus=event_bus, auth=auth, logger=logger)
|
|
322
|
+
result = await plugin.execute({"name": "Ana", "email": "a@b.com", "password": "secret123"})
|
|
323
|
+
|
|
324
|
+
assert result["success"] is True
|
|
325
|
+
rows = await db.query("SELECT name, email, password_hash FROM users")
|
|
326
|
+
assert rows[0]["password_hash"] != "secret123"
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Worked example: `tests/test_plugin_di_fixtures.py`.
|
|
330
|
+
|
|
331
|
+
- `@pytest.mark.migrations("users", "system")` applies those domains' real
|
|
332
|
+
migration files. **No marker = a real but empty database**, which is what a
|
|
333
|
+
test that never touches a table wants.
|
|
334
|
+
- The `db` fixture resolves the **active** db tool, so these tests keep working
|
|
335
|
+
after a PostgreSQL swap instead of dying at collection on a hardcoded import.
|
|
336
|
+
- Fixtures drive setup/shutdown tolerating sync OR async, exactly as the Kernel
|
|
337
|
+
does — `LoggerTool.shutdown()` is sync, `SqliteTool.shutdown()` is not.
|
|
338
|
+
- Need something these do not give you? Declare your own fixture with that
|
|
339
|
+
name; a local one always overrides the shared one.
|
|
340
|
+
|
|
341
|
+
### Deliberate constraint divergence
|
|
342
|
+
|
|
343
|
+
The `FieldDivergenceLinter` warns when sibling plugins in a domain validate the
|
|
344
|
+
same field differently. When that is on purpose, record it where it happens —
|
|
345
|
+
do not leave the warning standing:
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
password: str = Field(
|
|
349
|
+
min_length=1,
|
|
350
|
+
json_schema_extra={"divergence_ok": "login verifies against the hash"},
|
|
351
|
+
)
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The reason is required (an empty one is ignored), and the waived declaration
|
|
355
|
+
drops out of the comparison — the others are still compared with each other.
|
|
356
|
+
|
|
357
|
+
**Prefabs — read these before hand-rolling setup:**
|
|
358
|
+
|
|
359
|
+
| Helper | For |
|
|
360
|
+
|---|---|
|
|
361
|
+
| `tests/helpers/mock_db.py` | `async with db.transaction()` — a bare `AsyncMock` crashes there. `TxMock` / `FailingTxMock` |
|
|
362
|
+
| `tests/helpers/active_db.py` | A real DB tool with the domain's real migrations applied |
|
|
363
|
+
| `tests/helpers/async_wait.py` | `wait_until(...)` instead of a fixed sleep. Pass `describe=` so a rare timeout reports the observed state, not `<lambda at 0x...>` |
|
|
364
|
+
| `tests/helpers/trace_chains.py` | Asserting a full event chain across domains |
|
|
365
|
+
|
|
366
|
+
### 1. Black-Box Integration Testing (Recommended for DB/State/Events)
|
|
367
|
+
To prevent fragile mocks and ensure database queries actually run and pass syntax and dialect checks:
|
|
368
|
+
- Do NOT mock `db`, `event_bus`, or `state` tools unless absolutely necessary.
|
|
369
|
+
- Use the real `SqliteTool` configured with `:memory:` (fast, self-contained, no external database files needed).
|
|
370
|
+
- Apply migrations dynamically to the in-memory database to establish the real schema.
|
|
371
|
+
- Assert on the final state of the database and event bus (Black-Box) rather than internal mock call assertions.
|
|
372
|
+
|
|
373
|
+
Example of a Black-Box Integration Test:
|
|
374
|
+
```python
|
|
375
|
+
import pytest
|
|
376
|
+
from tools.sqlite.sqlite_tool import SqliteTool
|
|
377
|
+
from tools.event_bus.event_bus_tool import EventBusTool
|
|
378
|
+
from tools.auth.auth_tool import AuthTool
|
|
379
|
+
from domains.users.plugins.create_user_plugin import CreateUserPlugin
|
|
380
|
+
|
|
381
|
+
@pytest.fixture
|
|
382
|
+
async def test_env():
|
|
383
|
+
# Setup real db in memory
|
|
384
|
+
db = SqliteTool()
|
|
385
|
+
db._db_path = ":memory:"
|
|
386
|
+
await db.setup()
|
|
387
|
+
|
|
388
|
+
# Run the migrations needed for this domain
|
|
389
|
+
with open("domains/<your_domain>/migrations/001_create_<table>.sql", "r") as f:
|
|
390
|
+
await db.execute(f.read())
|
|
391
|
+
|
|
392
|
+
bus = EventBusTool()
|
|
393
|
+
await bus.setup()
|
|
394
|
+
|
|
395
|
+
auth = AuthTool()
|
|
396
|
+
await auth.setup()
|
|
397
|
+
|
|
398
|
+
yield db, bus, auth
|
|
399
|
+
|
|
400
|
+
await db.shutdown()
|
|
401
|
+
await bus.shutdown()
|
|
402
|
+
|
|
403
|
+
@pytest.mark.anyio
|
|
404
|
+
async def test_create_user_integration(test_env):
|
|
405
|
+
db, bus, auth = test_env
|
|
406
|
+
plugin = CreateUserPlugin(http=None, db=db, event_bus=bus, logger=None, auth=auth)
|
|
407
|
+
|
|
408
|
+
# Input
|
|
409
|
+
data = {"name": "John Doe", "email": "john@example.com", "password": "password123"}
|
|
410
|
+
|
|
411
|
+
# Execute (Black Box)
|
|
412
|
+
result = await plugin.execute(data)
|
|
413
|
+
|
|
414
|
+
# Output Verification
|
|
415
|
+
assert result["success"] is True
|
|
416
|
+
assert result["data"]["email"] == "john@example.com"
|
|
417
|
+
|
|
418
|
+
# Database Verification (No Mocks!)
|
|
419
|
+
users = await db.query("SELECT * FROM users WHERE email = $1", ["john@example.com"])
|
|
420
|
+
assert len(users) == 1
|
|
421
|
+
assert users[0]["name"] == "John Doe"
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Waiting for Async Delivery (Events, Retries, DLQ)
|
|
425
|
+
|
|
426
|
+
Never assert right after a fixed `await asyncio.sleep(...)`. A fixed sleep
|
|
427
|
+
guesses how long delivery takes; under CI CPU contention the guess loses and
|
|
428
|
+
the test flakes. Poll the real condition instead:
|
|
429
|
+
|
|
430
|
+
```python
|
|
431
|
+
from tests.helpers.async_wait import wait_for_dlq, wait_until
|
|
432
|
+
|
|
433
|
+
await bus.publish("user.created", payload)
|
|
434
|
+
await wait_until(lambda: len(received) == 1) # returns as soon as delivery lands
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
For **DLQ events** always use `wait_for_dlq(bus, "user.created")`, never plain
|
|
438
|
+
`wait_until` and never a hand-picked timeout: the DLQ only fires after retries
|
|
439
|
+
exhaust their exponential backoff, which can exceed `wait_until`'s default
|
|
440
|
+
timeout — the helper derives its deadline from the retries/backoff the
|
|
441
|
+
subscribers declared.
|
|
442
|
+
|
|
443
|
+
The one legitimate fixed sleep is a **negative check** (asserting that nothing
|
|
444
|
+
arrives) — there is no condition to poll for, so a short fixed wait is correct
|
|
445
|
+
there.
|
|
446
|
+
|
|
447
|
+
For asserting a full event chain across domains (flow tests), use the chain
|
|
448
|
+
helper in `tests/helpers/trace_chains.py`.
|
|
449
|
+
|
|
450
|
+
### 2. Isolated Unit Testing (For pure business logic / third-party mocks)
|
|
451
|
+
If you only need to verify branch/flow logic or if you have complex external dependencies (e.g., third-party APIs):
|
|
452
|
+
- Mock the specific tools using `unittest.mock.AsyncMock` or `MagicMock`.
|
|
453
|
+
- Keep assertions focused on inputs/outputs and mock side-effects.
|
|
454
|
+
- Any method the plugin uses as `async with` must be overridden with
|
|
455
|
+
`MagicMock` — a bare `AsyncMock` yields a coroutine there and crashes. For
|
|
456
|
+
`db.transaction()` use the prefab helpers:
|
|
457
|
+
`db.transaction = MagicMock(return_value=TxMock())` (happy path) or
|
|
458
|
+
`FailingTxMock()` (sad path), from `tests.helpers.mock_db`.
|
|
459
|
+
|
|
460
|
+
Example:
|
|
461
|
+
```python
|
|
462
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
463
|
+
from domains.users.plugins.create_user_plugin import CreateUserPlugin
|
|
464
|
+
|
|
465
|
+
@pytest.mark.anyio
|
|
466
|
+
async def test_create_user_unit():
|
|
467
|
+
mock_db = AsyncMock(execute=AsyncMock(return_value=42))
|
|
468
|
+
plugin = CreateUserPlugin(
|
|
469
|
+
http=MagicMock(), db=mock_db, event_bus=AsyncMock(),
|
|
470
|
+
logger=MagicMock(), auth=MagicMock(hash_password=AsyncMock(return_value="hashed"))
|
|
471
|
+
)
|
|
472
|
+
result = await plugin.execute({"name": "Test", "email": "a@b.com", "password": "p"})
|
|
473
|
+
assert result["success"] is True
|
|
474
|
+
assert result["data"]["id"] == 42
|
|
475
|
+
```
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## 📦 Available Extras
|
|
479
|
+
|
|
480
|
+
The project includes pre-built tools and domains in the `extras/` folder. These
|
|
481
|
+
are not active by default to keep the core lean.
|
|
482
|
+
|
|
483
|
+
### Activating an Extra
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
microcoreos add postgres
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
One command does all three acts: installs the optional dependency, moves the
|
|
490
|
+
source out of `extras/` into `tools/` (and `domains/` for a pair), and appends
|
|
491
|
+
the extra's settings to `.env` without overwriting values already there. Run
|
|
492
|
+
`microcoreos add` with no argument to list what is available.
|
|
493
|
+
|
|
494
|
+
By hand it is the same three acts, and each one fails in a different place:
|
|
495
|
+
|
|
496
|
+
```bash
|
|
497
|
+
uv add 'microcoreos[postgres]' # 1. the library
|
|
498
|
+
mv extras/available_tools/postgresql tools/ # 2. the source you now own
|
|
499
|
+
# 3. the settings — see the table below
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Skipping step 1 does not fail quietly — the Kernel reports a load error for
|
|
503
|
+
that file at boot (`No module named 'asyncpg'`). Skipping step 2 does nothing
|
|
504
|
+
at all: the Kernel only discovers what is under `tools/` and `domains/`.
|
|
505
|
+
|
|
506
|
+
| Extra | Package step | Folder step |
|
|
507
|
+
|---|---|---|
|
|
508
|
+
| **PostgreSQL** — takes over the `db` key; set `DATABASE_URL` in `.env` | `uv add 'microcoreos[postgres]'` | `mv extras/available_tools/postgresql tools/` |
|
|
509
|
+
| **Redis State** — swaps in-memory `state` for a distributed one | `uv add 'microcoreos[redis]'` | `mv extras/available_tools/redis_state tools/` |
|
|
510
|
+
| **S3** — object storage, private bucket + presigned URLs | `uv add 'microcoreos[s3]'` | `mv extras/available_tools/s3 tools/` |
|
|
511
|
+
| **Scheduler** — cron + in-memory one-shots | `uv add 'microcoreos[scheduler]'` | `mv extras/available_tools/scheduler tools/` |
|
|
512
|
+
| **Chaos** — resilience testing (tool + its domain) | none | `mv extras/available_tools/chaos tools/` and `mv extras/available_domains/chaos domains/` |
|
|
513
|
+
|
|
514
|
+
**Event Bus drivers** install by dropping the `*_driver.py` into
|
|
515
|
+
`tools/event_bus/` and setting `EVENT_BUS_DRIVER`: `kafka`
|
|
516
|
+
(`uv add 'microcoreos[kafka]'`), `rabbitmq` (`[rabbitmq]`). Redis Streams and
|
|
517
|
+
the durable SQLite driver already ship in `tools/event_bus/` — Redis Streams
|
|
518
|
+
still needs `uv add 'microcoreos[redis]'`.
|
|
519
|
+
|
|
520
|
+
**Domains that need a tool.** A plugin cannot exist without its tool, so a
|
|
521
|
+
domain extra always requires its tool extra first. `extras/available_domains/scheduler/`
|
|
522
|
+
(durable one-shots, the `scheduler.one_shot.*` bus API) needs the scheduler
|
|
523
|
+
tool installed by both steps above; without it the boot says
|
|
524
|
+
`Plugin scheduler.DurableOneShotsPlugin aborted: Missing tools: scheduler`.
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
528
|
+
## 📡 Observability Capabilities
|
|
529
|
+
|
|
530
|
+
### Tool call metrics via `registry`
|
|
531
|
+
Every tool call is timed by ToolProxy. Access last 1000 records via `registry.get_metrics()`.
|
|
532
|
+
|
|
533
|
+
### Native Pydantic Tracing
|
|
534
|
+
`event_bus.get_trace_history()` returns `List[TraceRecord]` (last 500 events).
|
|
535
|
+
Each `TraceRecord` has:
|
|
536
|
+
- `envelope`: The full `EventEnvelope` (metadata + payload).
|
|
537
|
+
- `subscribers`: List of names of handlers that received the event.
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## 🗄️ Swapping the Database Engine
|
|
542
|
+
The `db` injection key is the contract. Plugins use `$1, $2...` placeholders. SQLite converts them internally to `?`.
|
|
543
|
+
|
|
544
|
+
## 🗄️ Migration Dependency Ordering
|
|
545
|
+
The kernel **always** applies migrations via topological sort. Without `-- depends:`, the order is the discovery order (alphabetical by domain → alphabetical by filename). When a migration requires another to have run first, declare it on the first comment line:
|
|
546
|
+
|
|
547
|
+
```sql
|
|
548
|
+
-- depends: users/001_create_users.sql
|
|
549
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
550
|
+
id INTEGER PRIMARY KEY,
|
|
551
|
+
user_id INTEGER REFERENCES users(id),
|
|
552
|
+
...
|
|
553
|
+
);
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
This works for same-domain or cross-domain dependencies. The `.sql` extension is optional in the depends value.
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
*`AI_CONTEXT.md` is auto-generated on every boot by the `context_manager` tool. It contains the live inventory of tools and domain models.*
|