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

@@ -1,12 +1,10 @@
"""
Core domain models for activity-core.
T01: EventEnvelope — standard envelope for all inbound and outbound events.
T02: ActivityDefinition — versioned definition of a trigger + context resolver + task templates.
"""
from __future__ import annotations
import json
from typing import Annotated, Any, Literal, Union
from datetime import datetime
from uuid import UUID
@@ -14,67 +12,54 @@ from uuid import UUID
from pydantic import BaseModel, Field
# ── T01: Event Envelope ───────────────────────────────────────────────────────
# ── EventEnvelope (T40) ───────────────────────────────────────────────────────
class EventEnvelope(BaseModel):
"""Standard internal event envelope. Every event, whether time-fired or
broker-delivered, is normalised into this shape before processing."""
"""Standard internal event envelope. All inbound events (NATS, webhook, cron)
are normalised into this shape before processing."""
event_id: str = Field(
description="Stable unique ID. Used for deduplication: if an event with "
"this ID has already been processed, the router skips it."
)
type: str = Field(description="Dot-namespaced event type, e.g. 'user.created'.")
source: str = Field(description="Originating service or component, e.g. 'user-service'.")
occurred_at: datetime = Field(description="When the event occurred (UTC).")
subject: str = Field(description="Primary resource affected, e.g. 'user/123'.")
trace_id: str = Field(description="Distributed tracing correlation ID.")
schema_version: str = Field(
default="1.0",
description="Schema version string for forward-compatibility.",
)
payload: dict[str, Any] = Field(
id: str = Field(description="UUID v4 — stable unique ID for deduplication.")
type: str = Field(description="Dot-namespaced event type, e.g. 'org.repo.registered'.")
version: str = Field(default="1.0", description="Schema version string.")
timestamp: datetime = Field(description="When the event occurred (UTC).")
publisher: str = Field(description="Originating service, e.g. 'the-custodian/state-hub'.")
attributes: dict[str, Any] = Field(
default_factory=dict,
description="Event-specific data; structure varies by event type.",
description="Event-specific attributes; structure varies by event type.",
)
@classmethod
def from_nats_message(cls, msg: Any) -> "EventEnvelope":
"""Decode a NATS JetStream message into an EventEnvelope."""
raw = json.loads(msg.data.decode())
return cls.model_validate(raw)
# ── T02: ActivityDefinition ────────────────────────────────────────────────────
@classmethod
def from_webhook_payload(cls, source: str, payload: dict) -> "EventEnvelope":
"""Build an EventEnvelope from a raw webhook payload (pre-normalised)."""
return cls.model_validate(payload)
# ── Trigger configs ───────────────────────────────────────────────────────────
class CronTriggerConfig(BaseModel):
trigger_type: Literal["cron"] = "cron"
cron_expression: str = Field(
description="Standard 5-field cron expression, e.g. '0 9 * * 1-5'."
)
timezone: str = Field(
default="UTC",
description="IANA timezone name, e.g. 'Europe/Berlin'.",
)
jitter_seconds: int = Field(
default=0,
ge=0,
description="Maximum random delay (seconds) added to each trigger to spread load.",
)
misfire_policy: Literal["skip", "catchup", "compress"] = Field(
default="skip",
description=(
"skip: ignore any missed runs. "
"catchup: replay missed runs up to a bounded limit. "
"compress: run once covering the full missed window."
),
)
timezone: str = Field(default="UTC", description="IANA timezone name.")
jitter_seconds: int = Field(default=0, ge=0)
misfire_policy: Literal["skip", "catchup", "compress"] = Field(default="skip")
class EventTriggerConfig(BaseModel):
trigger_type: Literal["event"] = "event"
event_type: str = Field(
description="Matches EventEnvelope.type. The router fires this activity "
"when an event with this type is received."
description="Matches EventEnvelope.type. Router fires this activity on match."
)
filters: dict[str, Any] = Field(
default_factory=dict,
description="Optional predicate filters applied to EventEnvelope.payload "
"before routing. All filters must match for the activity to fire.",
description="All filters must match EventEnvelope.attributes for routing.",
)
@@ -84,75 +69,80 @@ TriggerConfig = Annotated[
]
# ── Rules and instructions (T34) ──────────────────────────────────────────────
class ActionDef(BaseModel):
task_template: str = Field(description="Path to task template .md, relative to repo root.")
target_repo: str | None = Field(
default=None,
description="Attribute-access expression or literal repo slug.",
)
priority: str = Field(default="medium")
labels: list[str] = Field(default_factory=list)
due_in_days: int | None = Field(default=None)
class RuleDef(BaseModel):
id: str
condition: str = Field(
default="",
description="Rule DSL expression; empty string means always true.",
)
action: ActionDef
class InstructionDef(BaseModel):
id: str
condition: str = Field(
default="",
description="Optional pre-filter using Rule DSL; empty means always execute.",
)
trusted_fields: list[str] = Field(
description="Allowlist of event/context fields that may appear in the prompt template.",
)
model: str = Field(description="LLM model identifier, e.g. 'claude-sonnet-4-6'.")
prompt: str = Field(description="Prompt template with {field.path} placeholders.")
output_schema: str = Field(description="Path to JSON Schema file for output validation.")
review_required: bool = Field(default=False)
# ── Context sources ───────────────────────────────────────────────────────────
class ContextSource(BaseModel):
"""Describes one external data source that the workflow queries to build
the context snapshot passed to evaluate_templates."""
"""One external data source that the workflow queries to build the context snapshot."""
name: str = Field(
description="Logical name; referenced as 'context.<name>' in task templates."
)
type: str = Field(
description="Source adapter type: 'db_query' | 'http_get' | 'static'."
)
config: dict[str, Any] = Field(
default_factory=dict,
description="Source-specific configuration (SQL, URL, static value, etc.).",
)
name: str = Field(description="Logical name; referenced as 'context.<name>' in templates.")
type: str = Field(description="Source adapter type: 'repo-scoping' | 'state-hub' | etc.")
query: str = Field(default="", description="Named query to execute against the source.")
params: dict[str, Any] = Field(default_factory=dict)
bind_to: str = Field(default="", description="Context key to bind the result to.")
# ── Task templates (legacy) ───────────────────────────────────────────────────
class TaskTemplate(BaseModel):
"""Template for one task instance produced by RunActivityWorkflow.
"""Legacy task template — ignored when ActivityDefinition.rules is non-empty."""
evaluate_templates() expands each template against the context snapshot
to produce a concrete TaskInstance."""
task_type: str
condition: str | None = None
params_template: dict[str, Any] = Field(default_factory=dict)
task_type: str = Field(
description="Maps to a registered TaskExecutorWorkflow type, e.g. 'send_email'."
)
condition: str | None = Field(
default=None,
description=(
"Optional Python expression evaluated against the context snapshot. "
"Task is skipped if the expression is falsy. "
"Example: \"context['user']['is_active'] == True\""
),
)
params_template: dict[str, Any] = Field(
default_factory=dict,
description=(
"Parameter template. String values starting with '{context.' are "
"interpolated from the context snapshot at evaluation time."
),
)
# ── ActivityDefinition ────────────────────────────────────────────────────────
class ActivityDefinition(BaseModel):
"""Versioned definition of a single activity: its trigger, context resolution
strategy, and the task templates it can spawn."""
"""Versioned definition: trigger + context sources + rules/instructions."""
id: UUID = Field(
description="Stable UUID. Used as the Temporal Schedule ID prefix "
"(f'activity-schedule-{id}') and as the workflow ID component."
)
name: str = Field(description="Human-readable name.")
enabled: bool = Field(
default=True,
description="When False the corresponding Temporal Schedule is paused "
"and event routing is suppressed.",
)
trigger_config: TriggerConfig = Field(
description="Cron or event trigger configuration."
)
id: UUID
name: str
enabled: bool = True
trigger_config: TriggerConfig
context_sources: list[ContextSource] = Field(default_factory=list)
# New rule/instruction pipeline (T34)
rules: list[RuleDef] = Field(default_factory=list)
instructions: list[InstructionDef] = Field(default_factory=list)
# Legacy — ignored when rules is non-empty
task_templates: list[TaskTemplate] = Field(default_factory=list)
dedupe_key_strategy: Literal["skip", "catchup", "compress"] = Field(
default="skip",
description="How to handle duplicate or missed trigger events. "
"Should match CronTriggerConfig.misfire_policy for cron activities.",
)
version: int = Field(
default=1,
ge=1,
description="Incremented on breaking schema changes. Stored in activity_runs "
"for audit purposes.",
)
dedupe_key_strategy: Literal["skip", "catchup", "compress"] = Field(default="skip")
version: int = Field(default=1, ge=1)
status: str = Field(default="active")