feat(event-bridge): WP-0003a — domain model, rules module, event type registry

Implements phases 7–8 of the Event Bridge architecture (custodian-WP-0003a).

Domain model (T34, T40):
- Added RuleDef, InstructionDef, ActionDef to models.py
- Updated ActivityDefinition with rules/instructions fields (task_templates deprecated)
- Formalized EventEnvelope: id, type, version, timestamp, publisher, attributes
- Added from_nats_message() and from_webhook_payload() classmethods

Rules module (T35, T36, T37):
- src/activity_core/rules/ skeleton with boundary enforcement
- evaluate_condition() — sandboxed AST walker, whitelisted nodes only, never exec()
- execute_instruction() — LLM task generation with trusted_fields injection guard
- tests/rules/test_boundary.py verifies no cross-boundary imports

Infrastructure (T38, T39):
- Alembic migrations 0004 (task_spawn_log) and 0005 (event_types)
- IssueSink ABC + IssueCoreRestSink (REST) + NullSink (testing)
- TaskSpawnLog and EventType ORM models

Event type registry (T41, T42, T43):
- event_type_registry.py: file scanner, parser, DB sync, in-process lookup
- ACTIVITY_CURATOR_GATE env var (disabled|required) + approve endpoint
- Three org event type definitions: org.repo.registered, org.workstream.completed,
  org.activity.run.completed

All 10 tests pass. Boundary test confirms rules/ isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:01:15 +02:00
parent ee81adb2fa
commit c3a256509b
22 changed files with 1281 additions and 137 deletions

View File

@@ -21,7 +21,6 @@ Usage:
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
@@ -112,9 +111,9 @@ class EventRouter:
"""Return True if the envelope matches the EventTriggerConfig."""
if envelope.type != cfg.event_type:
return False
# All filter key/value pairs must be present in envelope.payload.
# All filter key/value pairs must be present in envelope.attributes.
for key, value in cfg.filters.items():
if envelope.payload.get(key) != value:
if envelope.attributes.get(key) != value:
return False
return True
@@ -122,15 +121,15 @@ class EventRouter:
async def _dispatch(self, activity_id: str, envelope: EventEnvelope) -> None:
"""Start RunActivityWorkflow for one matched activity.
Workflow ID is deterministic: activity-{activity_id}:{event_id}
Workflow ID is deterministic: activity-{activity_id}:{id}
REJECT_DUPLICATE prevents double-processing if the message is redelivered
before ack reaches NATS.
"""
workflow_id = f"activity-{activity_id}:{envelope.event_id}"
workflow_id = f"activity-{activity_id}:{envelope.id}"
try:
await self._temporal.start_workflow(
"RunActivityWorkflow",
args=[activity_id, envelope.event_id, envelope.occurred_at.isoformat()],
args=[activity_id, envelope.id, envelope.timestamp.isoformat()],
id=workflow_id,
task_queue=_ORCHESTRATOR_TASK_QUEUE,
id_conflict_policy=WorkflowIDConflictPolicy.FAIL,
@@ -138,18 +137,17 @@ class EventRouter:
logger.info(
"started workflow %r for event %r (activity %s)",
workflow_id,
envelope.event_id,
envelope.id,
activity_id,
)
except WorkflowAlreadyStartedError:
# Duplicate delivery — workflow already running or completed; safe to skip.
logger.debug("duplicate event %r for activity %s — skipped", envelope.event_id, activity_id)
logger.debug("duplicate event %r for activity %s — skipped", envelope.id, activity_id)
async def _handle_message(self, msg: Any) -> None:
"""Decode a NATS message, match it against routing rules, and dispatch."""
try:
raw = json.loads(msg.data.decode())
envelope = EventEnvelope.model_validate(raw)
envelope = EventEnvelope.from_nats_message(msg)
except Exception:
logger.warning("failed to parse event envelope from NATS message — nacking")
await msg.nak()
@@ -160,7 +158,7 @@ class EventRouter:
matched = [aid for aid, cfg in event_defs if self._matches(envelope, cfg)]
if not matched:
logger.debug("event %r type=%r matched no definitions", envelope.event_id, envelope.type)
logger.debug("event %r type=%r matched no definitions", envelope.id, envelope.type)
await msg.ack()
return