generated from coulomb/repo-seed
docs(adr): establish three foundational ADRs for Event Bridge architecture
ADR-001: activity-core as org-wide Event Bridge — boundaries, NATS as org infrastructure, state hub delegation, rules-core module-first, issue-core adapter interface, capabilities domain assignment. ADR-002: markdown-as-definition format for event types and ActivityDefinitions — co-located intent/schema/logic/debugging, publisher-declared governance with environment-configurable curator gate, attribute type system, task template files. ADR-003: Rule vs. Instruction model and expression DSL — sandboxed Python-like AST evaluator for Rules, trusted-fields prompt injection protection for Instructions, output schema enforcement, audit trail, testing strategy, rules-core module boundary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
190
docs/adr/adr-001-event-bridge-architecture.md
Normal file
190
docs/adr/adr-001-event-bridge-architecture.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
---
|
||||||
|
id: ACT-ADR-001
|
||||||
|
type: architecture-decision-record
|
||||||
|
title: "Activity-Core as Coulomb Org Event Bridge"
|
||||||
|
status: accepted
|
||||||
|
decided_by: Bernd Worsch
|
||||||
|
date: "2026-05-14"
|
||||||
|
scope: cross-repo
|
||||||
|
affects:
|
||||||
|
- activity-core
|
||||||
|
- the-custodian/state-hub
|
||||||
|
- issue-facade (→ issue-core)
|
||||||
|
- repo-scoping
|
||||||
|
tags: ["architecture", "event-bridge", "activity-core", "orchestration", "event-loop"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ACT-ADR-001: Activity-Core as Coulomb Org Event Bridge
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Coulomb organization's set of repositories, services, and deployments is growing
|
||||||
|
beyond what a single person can coordinate manually. The state hub tracks cross-domain
|
||||||
|
state but has no mechanism to automatically respond to it. Recurring maintenance
|
||||||
|
(dependency scans, SBOM staleness checks, consistency audits) is implemented as
|
||||||
|
bespoke cron jobs baked into individual services — scattered, hard to audit, and
|
||||||
|
impossible to govern from a single vantage point.
|
||||||
|
|
||||||
|
Three forces drive the need for a dedicated orchestration layer:
|
||||||
|
|
||||||
|
1. **Scale**: as the repo count grows, manual coordination becomes the bottleneck.
|
||||||
|
2. **Reactivity**: org-level events (new repo registered, CVE published, deployment
|
||||||
|
completed) should trigger coordinated responses without human intervention.
|
||||||
|
3. **Separation of concerns**: the state hub is a read model and should remain one.
|
||||||
|
It must not accumulate automation logic to avoid becoming a God object.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**activity-core is the org-wide Event Bridge for the Coulomb organization.**
|
||||||
|
|
||||||
|
Its responsibility is exactly three things:
|
||||||
|
1. **Receive events** — time-based (cron, one-off scheduled) and domain events (NATS,
|
||||||
|
Gitea webhooks, state hub lifecycle signals).
|
||||||
|
2. **Evaluate rules and instructions** — given event payload and resolved context,
|
||||||
|
determine what work must be created.
|
||||||
|
3. **Emit task sets** — publish structured task creation requests to issue-core.
|
||||||
|
|
||||||
|
It does not execute work. It does not track task lifecycle. It does not manage projects.
|
||||||
|
|
||||||
|
### Boundary rules
|
||||||
|
|
||||||
|
| Concern | Owner |
|
||||||
|
|---|---|
|
||||||
|
| Cross-org task scheduling and reactive automation | **activity-core** |
|
||||||
|
| Task lifecycle (create, assign, track, close) | **issue-core** |
|
||||||
|
| Project and initiative management (phased, completion-gated) | **project-core** (future) |
|
||||||
|
| Repository capability profiling | **repo-scoping** |
|
||||||
|
| Cross-domain coordination state | **state hub** |
|
||||||
|
| Execution of automatable tasks | Temporal workers (per-repo) |
|
||||||
|
|
||||||
|
### Event type registry
|
||||||
|
|
||||||
|
Event types are declared by publishers as markdown definition files (see ACT-ADR-002).
|
||||||
|
Governance is **publisher-declared by default**: a publisher registers its event types
|
||||||
|
by committing definition files to the event-types registry. In production environments,
|
||||||
|
a curator gate can be enabled — registry entries must be reviewed before the runtime
|
||||||
|
accepts events of that type. This is a configuration flag per runtime scope (dev,
|
||||||
|
staging, prod), not a hard-coded rule.
|
||||||
|
|
||||||
|
### State hub relationship
|
||||||
|
|
||||||
|
The state hub **delegates automation to activity-core** rather than implementing it
|
||||||
|
internally. Concretely:
|
||||||
|
|
||||||
|
- Maintenance jobs currently baked into the state hub (consistency sync, SBOM
|
||||||
|
staleness checks) are migrated to ActivityDefinitions in activity-core.
|
||||||
|
- The state hub becomes a **publisher** of lifecycle events on NATS
|
||||||
|
(`org.workstream.created`, `org.decision.resolved`, `org.repo.registered`, etc.).
|
||||||
|
- The state hub does not subscribe to activity-core's output directly; it reads
|
||||||
|
task state from issue-core when needed.
|
||||||
|
|
||||||
|
This preserves the state hub as a read model and makes activity-core the single
|
||||||
|
home for automation policy.
|
||||||
|
|
||||||
|
### rules-core: module-first
|
||||||
|
|
||||||
|
The rule and instruction evaluation engine starts as `src/activity_core/rules/` — a
|
||||||
|
module with a clean internal boundary (no imports from Temporal, Postgres, or FastAPI
|
||||||
|
within the module). Extraction to a standalone `rules-core` repository happens when a
|
||||||
|
**second consumer** (e.g. state hub governance, project-core) needs the engine. This
|
||||||
|
follows the same discipline as the task-flow-engine extraction plan (CUST-TFE-SCOPE).
|
||||||
|
|
||||||
|
### NATS as org infrastructure
|
||||||
|
|
||||||
|
NATS JetStream is promoted from an activity-core internal component to **org-wide
|
||||||
|
event bus infrastructure**. It runs as a standalone service (not bundled in
|
||||||
|
activity-core's docker-compose) with its own lifecycle. All services that publish
|
||||||
|
or subscribe to org events do so via NATS streams.
|
||||||
|
|
||||||
|
### issue-core integration
|
||||||
|
|
||||||
|
activity-core communicates with issue-core via a **task emission adapter** — an
|
||||||
|
abstraction layer that, in the initial implementation, calls issue-core's REST API.
|
||||||
|
The adapter interface is defined now; the transport can migrate to NATS subscription
|
||||||
|
(issue-core subscribes to `task.spawned` events) once issue-core adds that capability.
|
||||||
|
This avoids hardcoding REST coupling throughout the codebase.
|
||||||
|
|
||||||
|
### Webhook receiver
|
||||||
|
|
||||||
|
A new HTTP endpoint within activity-core accepts inbound webhooks from Gitea (and
|
||||||
|
later GitHub, other services). It normalises payloads to the canonical EventEnvelope
|
||||||
|
format, validates against the event type registry, and publishes to NATS. This runs
|
||||||
|
alongside the existing FastAPI `api.py`.
|
||||||
|
|
||||||
|
### Domain assignment
|
||||||
|
|
||||||
|
activity-core and issue-core are assigned to the **`capabilities`** domain — the
|
||||||
|
same domain as repo-scoping. These are org-wide infrastructure tools that serve all
|
||||||
|
domains equally, not artefacts of any single project or custodian's personal workflow.
|
||||||
|
issue-core is explicitly disassociated from the markitect domain.
|
||||||
|
|
||||||
|
## Trigger types
|
||||||
|
|
||||||
|
Three trigger types are supported:
|
||||||
|
|
||||||
|
| Type | Description | Temporal mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| `cron` | Recurring schedule (5-field cron + timezone + misfire policy) | Temporal Schedule (implemented WP-0002) |
|
||||||
|
| `event` | React to a named event type on NATS | Temporal workflow started by Event Router |
|
||||||
|
| `scheduled` | One-off at a future datetime | Temporal Schedule with `remaining_actions: 1` |
|
||||||
|
|
||||||
|
`scheduled` is a new trigger type added in WP-0003.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Immediate
|
||||||
|
|
||||||
|
- activity-core's `INTENT.md` and `SCOPE.md` are rewritten to reflect this architecture.
|
||||||
|
- The `task_instances` Postgres table is reclassified as a **spawn audit trail** —
|
||||||
|
it records the act of spawning (what was created, when, which issue-core reference)
|
||||||
|
but is not the authoritative task record. Authoritative lifecycle state lives in
|
||||||
|
issue-core.
|
||||||
|
- A task emission adapter interface (`src/activity_core/issue_sink.py`) replaces any
|
||||||
|
direct Postgres writes to `task_instances` with calls through the adapter.
|
||||||
|
- The `TaskExecutorWorkflow` stub from WP-0001 is replaced with the actual adapter
|
||||||
|
call in WP-0003.
|
||||||
|
|
||||||
|
### Medium term
|
||||||
|
|
||||||
|
- State hub adds NATS publishing to its lifecycle operations.
|
||||||
|
- Gitea webhook receiver added to activity-core as a new HTTP router.
|
||||||
|
- Existing state hub maintenance crons are migrated to ActivityDefinitions.
|
||||||
|
- issue-facade is renamed issue-core and re-registered under the `capabilities` domain.
|
||||||
|
|
||||||
|
### Long term
|
||||||
|
|
||||||
|
- rules-core extracted as a standalone package when a second consumer appears.
|
||||||
|
- project-core created (depends on task-flow-engine extraction) for multi-phase
|
||||||
|
initiative management — explicitly out of scope for activity-core.
|
||||||
|
- NATS gets its own operational runbook and monitoring as org infrastructure.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**State hub absorbs activity-core functionality**: rejected — turns the state hub into
|
||||||
|
a God object, violates the read-model boundary, and makes automation logic impossible
|
||||||
|
to test independently.
|
||||||
|
|
||||||
|
**Per-repo automation (GitHub Actions style)**: rejected — cross-repo coordination
|
||||||
|
requires a single vantage point that can see all repos; per-repo actions can't express
|
||||||
|
org-level triggers or context.
|
||||||
|
|
||||||
|
**Activity-core as a thin Temporal wrapper only**: rejected — without the event type
|
||||||
|
registry and rule model, it's just a scheduler. The governance and introspection
|
||||||
|
properties are the point.
|
||||||
|
|
||||||
|
**Separate rules-core from day one**: rejected — premature extraction adds dependency
|
||||||
|
management overhead before a second consumer exists. Module-first with a clean boundary
|
||||||
|
costs nothing and preserves the extraction option.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- ACT-ADR-002 — Event type and ActivityDefinition definition format
|
||||||
|
- ACT-ADR-003 — Rule vs. Instruction model and DSL
|
||||||
|
- CUST-ADR-001 — Workplans as repository artefacts (canon/architecture/)
|
||||||
|
- CUST-TFE-SCOPE-2026-000001 — task-flow-engine extraction plan (canon/projects/)
|
||||||
|
- activity-core INTENT.md (to be written)
|
||||||
|
- activity-core WP-0003 (to be written)
|
||||||
356
docs/adr/adr-002-definition-format.md
Normal file
356
docs/adr/adr-002-definition-format.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
---
|
||||||
|
id: ACT-ADR-002
|
||||||
|
type: architecture-decision-record
|
||||||
|
title: "Markdown-as-Definition Format for Event Types and ActivityDefinitions"
|
||||||
|
status: accepted
|
||||||
|
decided_by: Bernd Worsch
|
||||||
|
date: "2026-05-14"
|
||||||
|
scope: cross-repo
|
||||||
|
affects:
|
||||||
|
- activity-core
|
||||||
|
- any event publisher registering event types
|
||||||
|
tags: ["architecture", "format", "event-type", "activity-definition", "markdown", "documentation"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ACT-ADR-002: Markdown-as-Definition Format
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Event type schemas and ActivityDefinition rules need to be understood and authored
|
||||||
|
by three distinct audiences simultaneously: humans reviewing and debugging automation,
|
||||||
|
agents creating and modifying definitions at runtime, and machines parsing and
|
||||||
|
evaluating them. Traditional approaches split these concerns — schemas go in JSON
|
||||||
|
Schema or YAML, documentation goes in a wiki, logic goes in code — and they drift
|
||||||
|
apart. A bug in a rule requires cross-referencing three places to understand intent,
|
||||||
|
check the schema, and read the condition.
|
||||||
|
|
||||||
|
The Custodian ecosystem already uses markdown files with YAML frontmatter as the
|
||||||
|
authoritative format for workplans, ADRs, SCOPE.md, and INTENT.md — all understood
|
||||||
|
by humans and agents without additional tooling. The same pattern should apply here.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Event type definitions and ActivityDefinitions are markdown files** where machine-
|
||||||
|
parseable structure (frontmatter YAML and fenced definition blocks) is embedded within
|
||||||
|
human-readable narrative. Intent, schema, logic, and debugging notes live in one file.
|
||||||
|
|
||||||
|
### Event Type Definition Files
|
||||||
|
|
||||||
|
**Location**: `event-types/{namespace}.{event-name}.md` within the activity-core repo
|
||||||
|
(or a registered event-types registry repo if volumes justify separation).
|
||||||
|
|
||||||
|
**Naming convention**: `{publisher-domain}.{noun}.{verb}.md`, e.g.:
|
||||||
|
- `org.repo.registered.md`
|
||||||
|
- `org.security.cve.published.md`
|
||||||
|
- `org.workstream.completed.md`
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: org.repo.registered
|
||||||
|
type: event-type
|
||||||
|
version: "1.0"
|
||||||
|
publisher: the-custodian/state-hub
|
||||||
|
governance: publisher-declared # publisher-declared | curated
|
||||||
|
status: active # active | deprecated | draft
|
||||||
|
introduced: "2026-05-14"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Event: org.repo.registered
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
One-paragraph statement of why this event exists and what it signals.
|
||||||
|
Written for an agent or human who has never seen it before.
|
||||||
|
|
||||||
|
## When Published
|
||||||
|
|
||||||
|
Bulleted list of the exact conditions under which the publisher fires this event.
|
||||||
|
Be precise — ambiguity here causes missed or duplicate activations.
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
| Attribute | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `repo_slug` | string | yes | URL-safe repository identifier |
|
||||||
|
| `domain` | string | yes | Domain slug the repo is assigned to |
|
||||||
|
| `tags` | string[] | no | Capability tags set at registration time |
|
||||||
|
| `registered_at` | datetime | yes | ISO 8601 UTC timestamp |
|
||||||
|
|
||||||
|
## Example Payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "evt-7f3a1b2c",
|
||||||
|
"type": "org.repo.registered",
|
||||||
|
"version": "1.0",
|
||||||
|
"timestamp": "2026-05-14T10:00:00Z",
|
||||||
|
"publisher": "the-custodian/state-hub",
|
||||||
|
"attributes": {
|
||||||
|
"repo_slug": "new-python-service",
|
||||||
|
"domain": "railiance",
|
||||||
|
"tags": ["python-service", "fastapi"],
|
||||||
|
"registered_at": "2026-05-14T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consumer Notes
|
||||||
|
|
||||||
|
Guidance for agents and humans writing rules against this event type:
|
||||||
|
- Which attributes are safe for instruction prompts (trusted fields)
|
||||||
|
- Common misuses or gotchas
|
||||||
|
- Related events that are often used together
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
What to check when an activity that subscribes to this event does not fire:
|
||||||
|
- How to verify the event was published (NATS subject, log entry)
|
||||||
|
- How to inspect the event payload in the registry
|
||||||
|
- Common schema validation failures
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attribute Types
|
||||||
|
|
||||||
|
The type system for event attributes is intentionally small:
|
||||||
|
|
||||||
|
| Type | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `string` | UTF-8 string |
|
||||||
|
| `integer` | 64-bit signed integer |
|
||||||
|
| `float` | 64-bit float |
|
||||||
|
| `boolean` | true / false |
|
||||||
|
| `datetime` | ISO 8601 UTC string in payload, parsed to datetime in evaluator |
|
||||||
|
| `uuid` | String in payload, validated as UUID v4 |
|
||||||
|
| `string[]` | JSON array of strings |
|
||||||
|
| `integer[]` | JSON array of integers |
|
||||||
|
| `object` | Freeform JSON object — cannot be used in rule conditions; instruction-only |
|
||||||
|
|
||||||
|
`object` type attributes are available to instructions but excluded from rule
|
||||||
|
conditions deliberately — rules must be deterministic and schema-validatable.
|
||||||
|
|
||||||
|
### ActivityDefinition Files
|
||||||
|
|
||||||
|
**Location**: `activity-definitions/{slug}.md` within the repo that owns the automation.
|
||||||
|
For org-wide automations: `activity-core/activity-definitions/`.
|
||||||
|
For domain-specific automations: `{domain-repo}/activity-definitions/`.
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: ACT-DEF-onboard-python-repo
|
||||||
|
type: activity-definition
|
||||||
|
version: "1.0"
|
||||||
|
status: active
|
||||||
|
trigger:
|
||||||
|
type: event # event | cron | scheduled
|
||||||
|
event_type: org.repo.registered # for type: event
|
||||||
|
# cron: "0 9 * * 1" # for type: cron (5-field, UTC)
|
||||||
|
# timezone: "Europe/Berlin" # optional, cron only
|
||||||
|
# misfire_policy: skip # skip | catchup | compress (cron only)
|
||||||
|
# at: "2026-06-01T09:00:00Z" # for type: scheduled (one-off)
|
||||||
|
context_sources:
|
||||||
|
- type: repo-scoping
|
||||||
|
query: repo_profile
|
||||||
|
bind_to: context.repo_profile
|
||||||
|
- type: state-hub
|
||||||
|
query: domain_summary
|
||||||
|
bind_to: context.domain_summary
|
||||||
|
governance: publisher-declared
|
||||||
|
owner: custodian-agent
|
||||||
|
created: "2026-05-14"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ActivityDefinition: Onboard New Python Service
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
One paragraph. What does this automation do and why does it exist? What problem
|
||||||
|
would accumulate if this automation were turned off?
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Which event type fires this activity, and under what conditions does it apply?
|
||||||
|
Cross-reference the event type definition file.
|
||||||
|
|
||||||
|
## Context Sources
|
||||||
|
|
||||||
|
What context is resolved before rules are evaluated? Explain what each source
|
||||||
|
provides and why it is needed.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Each rule is a fenced block tagged `rule`. Rules are evaluated in order; all
|
||||||
|
matching rules fire (not first-match-only). See ACT-ADR-003 for the expression
|
||||||
|
language specification.
|
||||||
|
|
||||||
|
```rule
|
||||||
|
id: create-sbom-scan
|
||||||
|
condition: '"python-service" in event.attributes.tags'
|
||||||
|
action:
|
||||||
|
task_template: tasks/sbom-initial-scan.md
|
||||||
|
target_repo: event.attributes.repo_slug
|
||||||
|
priority: high
|
||||||
|
labels: ["onboarding", "security"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```rule
|
||||||
|
id: create-scope-generation
|
||||||
|
condition: '"python-service" in event.attributes.tags and context.repo_profile.scope_md_exists == false'
|
||||||
|
action:
|
||||||
|
task_template: tasks/generate-scope-md.md
|
||||||
|
target_repo: event.attributes.repo_slug
|
||||||
|
priority: medium
|
||||||
|
labels: ["onboarding", "documentation"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Instructions are evaluated after all rules. An instruction asks an LLM to decide
|
||||||
|
what additional tasks (if any) to create. See ACT-ADR-003 for safety requirements.
|
||||||
|
|
||||||
|
```instruction
|
||||||
|
id: domain-specific-onboarding
|
||||||
|
condition: 'event.attributes.domain != "test_domain_v2"'
|
||||||
|
trusted_fields:
|
||||||
|
- event.attributes.repo_slug
|
||||||
|
- event.attributes.domain
|
||||||
|
- event.attributes.tags
|
||||||
|
model: claude-sonnet-4-6
|
||||||
|
review_required: false
|
||||||
|
prompt: |
|
||||||
|
A new repository has been registered in the Coulomb organization.
|
||||||
|
|
||||||
|
Repository: {event.attributes.repo_slug}
|
||||||
|
Domain: {event.attributes.domain}
|
||||||
|
Tags: {event.attributes.tags}
|
||||||
|
|
||||||
|
Based on the domain's current standards and the repository profile above,
|
||||||
|
determine what additional domain-specific onboarding tasks should be created
|
||||||
|
beyond the standard SBOM scan and SCOPE.md generation. Return an empty list
|
||||||
|
if no additional tasks are warranted.
|
||||||
|
output_schema: tasks/task-template-list-schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Templates
|
||||||
|
|
||||||
|
References to task template files used in rule actions. Each template is a
|
||||||
|
separate markdown file under `tasks/` that defines the task title, description
|
||||||
|
template, default labels, and default assignee logic.
|
||||||
|
|
||||||
|
- `tasks/sbom-initial-scan.md`
|
||||||
|
- `tasks/generate-scope-md.md`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Operational notes, edge cases, and context that does not fit elsewhere.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
Checklist for when this ActivityDefinition fires but produces unexpected output:
|
||||||
|
|
||||||
|
1. Was the triggering event published with the correct type and attributes?
|
||||||
|
2. Do the rule conditions evaluate as expected? (Use `make eval-rule` with a fixture)
|
||||||
|
3. Is issue-core reachable and configured for the target domain?
|
||||||
|
4. For instructions: check the audit log for the model response and output validation result.
|
||||||
|
|
||||||
|
## Change History
|
||||||
|
|
||||||
|
- v1.0 (2026-05-14): Initial definition
|
||||||
|
```
|
||||||
|
|
||||||
|
### Governance model
|
||||||
|
|
||||||
|
The `governance` field on an event type definition determines how the registry
|
||||||
|
runtime handles it:
|
||||||
|
|
||||||
|
| Value | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `publisher-declared` | Accepted immediately on publish; no review required |
|
||||||
|
| `curated` | Held in `pending` state until a curator approves via registry API |
|
||||||
|
|
||||||
|
The runtime checks the **environment's curator gate configuration** — not just the
|
||||||
|
file's governance field. An environment configured with `curator_gate: disabled`
|
||||||
|
treats all event types as `publisher-declared` regardless of the field value.
|
||||||
|
An environment with `curator_gate: required` treats all event types as `curated`
|
||||||
|
regardless of the field value. The field is the publisher's declared preference;
|
||||||
|
the environment config is the enforcement point.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- **Dev / integration**: `curator_gate: disabled` — developers and agents iterate
|
||||||
|
freely; new event types take effect immediately.
|
||||||
|
- **Staging / production**: `curator_gate: required` — all new event types queue
|
||||||
|
for curator review before the runtime accepts events of that type.
|
||||||
|
|
||||||
|
### File as source of truth
|
||||||
|
|
||||||
|
Following CUST-ADR-001 (Workplans as Repository Artefacts), definition files are
|
||||||
|
the canonical source of truth. The activity-core runtime indexes them into its
|
||||||
|
database on startup and via a sync command. The database is a queryable cache,
|
||||||
|
not the origin. A definition deleted from the filesystem is disabled at next sync.
|
||||||
|
|
||||||
|
### Task Templates
|
||||||
|
|
||||||
|
Task templates are separate markdown files (`tasks/{slug}.md`) referenced from
|
||||||
|
ActivityDefinition action blocks. They define:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: tasks/sbom-initial-scan
|
||||||
|
type: task-template
|
||||||
|
---
|
||||||
|
# Task: Run Initial SBOM Scan
|
||||||
|
|
||||||
|
## Title template
|
||||||
|
`Run SBOM scan — {target_repo}`
|
||||||
|
|
||||||
|
## Description template
|
||||||
|
Initial SBOM scan required for newly registered repository `{target_repo}`.
|
||||||
|
Run: `make ingest-sbom REPO={target_repo} SCAN=1`
|
||||||
|
|
||||||
|
## Default labels
|
||||||
|
["sbom", "security", "automated"]
|
||||||
|
|
||||||
|
## Default assignee
|
||||||
|
None (unassigned)
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps task content editable separately from the routing logic in
|
||||||
|
ActivityDefinitions.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A new `event-types/` directory in activity-core (and eventually a shared registry)
|
||||||
|
holds all org event type definitions.
|
||||||
|
- A new `activity-definitions/` directory in activity-core holds org-wide automations.
|
||||||
|
- Domain repos may hold their own `activity-definitions/` for domain-specific
|
||||||
|
automations, scanned by activity-core at sync time.
|
||||||
|
- The runtime requires a parser for the `rule` and `instruction` fenced blocks.
|
||||||
|
- SCOPE.md for activity-core must be updated to list these directories.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Pure JSON Schema for event types, separate wiki for docs**: rejected — documentation
|
||||||
|
and schema diverge immediately; agents must cross-reference two systems to author
|
||||||
|
a rule correctly.
|
||||||
|
|
||||||
|
**OpenAPI / AsyncAPI specification**: rejected — those formats are excellent for
|
||||||
|
API and broker documentation but not designed for co-locating operational intent
|
||||||
|
and debugging guidance. They are also less readable for non-specialists.
|
||||||
|
|
||||||
|
**Code-only (Python dataclasses for event schemas, Python functions for rules)**:
|
||||||
|
rejected — requires code deployment for any definition change; agents cannot modify
|
||||||
|
definitions without write access to the codebase; non-technical stakeholders cannot
|
||||||
|
review or understand automation policies.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- ACT-ADR-001 — Event Bridge Architecture
|
||||||
|
- ACT-ADR-003 — Rule vs. Instruction model and DSL
|
||||||
|
- CUST-ADR-001 — Workplans as repository artefacts
|
||||||
281
docs/adr/adr-003-rule-instruction-model.md
Normal file
281
docs/adr/adr-003-rule-instruction-model.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
id: ACT-ADR-003
|
||||||
|
type: architecture-decision-record
|
||||||
|
title: "Rule vs. Instruction Model and Expression DSL"
|
||||||
|
status: accepted
|
||||||
|
decided_by: Bernd Worsch
|
||||||
|
date: "2026-05-14"
|
||||||
|
scope: cross-repo
|
||||||
|
affects:
|
||||||
|
- activity-core
|
||||||
|
- rules-core (future extraction)
|
||||||
|
tags: ["architecture", "rules", "instructions", "dsl", "llm", "safety", "evaluation"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ACT-ADR-003: Rule vs. Instruction Model and Expression DSL
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ActivityDefinitions need two distinct evaluation modes to cover the full range
|
||||||
|
of automation scenarios in the Coulomb org:
|
||||||
|
|
||||||
|
**Deterministic cases**: "if this repo has tag `python-service` AND has no SBOM
|
||||||
|
in the last 30 days, create a scan task." The condition is fully expressible as a
|
||||||
|
boolean predicate over known attributes. The output is fixed by the template. No
|
||||||
|
ambiguity, no LLM required, fully testable.
|
||||||
|
|
||||||
|
**Judgement cases**: "a new repository has been registered — based on its domain
|
||||||
|
and profile, determine what domain-specific onboarding tasks are appropriate." The
|
||||||
|
right answer depends on context that is expensive to encode as explicit rules. An
|
||||||
|
LLM is a better evaluator than a rule tree, but introduces non-determinism, cost,
|
||||||
|
and a new attack surface (prompt injection via event payload).
|
||||||
|
|
||||||
|
Conflating these two modes into one mechanism produces a system that is either
|
||||||
|
too rigid (rules only) or too unpredictable (LLM everywhere). The two modes
|
||||||
|
need different evaluation pipelines, testing strategies, and audit trails.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Two named, distinct evaluation modes: Rule and Instruction.**
|
||||||
|
|
||||||
|
Terminology is deliberate. A **Rule** is deterministic and mechanical — it applies
|
||||||
|
or it does not. An **Instruction** is contextual and interpretive — it guides an
|
||||||
|
LLM agent to make a judgement call. Both are expressed as fenced blocks in
|
||||||
|
ActivityDefinition markdown files (see ACT-ADR-002).
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
A Rule has two parts: a **condition** (boolean predicate) and one or more
|
||||||
|
**actions** (task template references).
|
||||||
|
|
||||||
|
#### Condition expression language
|
||||||
|
|
||||||
|
The condition is a single-line string expression evaluated by a sandboxed
|
||||||
|
AST walker — never `exec()` or `eval()`. The evaluator walks the parsed AST
|
||||||
|
and whitelist-checks every node type before executing. Unknown node types
|
||||||
|
raise an `UnsafeExpression` error at parse time, not at evaluation time.
|
||||||
|
|
||||||
|
**Available operations**:
|
||||||
|
|
||||||
|
| Category | Syntax | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Equality | `==`, `!=` | `event.type == "org.repo.registered"` |
|
||||||
|
| Comparison | `>`, `<`, `>=`, `<=` | `event.attributes.sbom_age_days > 30` |
|
||||||
|
| Membership | `in`, `not in` | `"python-service" in event.attributes.tags` |
|
||||||
|
| Boolean | `and`, `or`, `not` | `a and (b or not c)` |
|
||||||
|
| Grouping | `( )` | `(a or b) and c` |
|
||||||
|
| Length | `len(x)` | `len(event.attributes.affected_repos) > 0` |
|
||||||
|
| Existence | `x is None`, `x is not None` | `event.attributes.domain is not None` |
|
||||||
|
|
||||||
|
**Attribute access** follows dot notation on the `event` object and the `context`
|
||||||
|
object (populated by context sources declared in the ActivityDefinition):
|
||||||
|
|
||||||
|
- `event.id` — UUID string
|
||||||
|
- `event.type` — event type identifier
|
||||||
|
- `event.version` — event type version
|
||||||
|
- `event.timestamp` — ISO 8601 datetime string
|
||||||
|
- `event.publisher` — publisher identifier
|
||||||
|
- `event.attributes.{name}` — typed attribute per event type schema
|
||||||
|
- `context.{source}.{field}` — resolved context data
|
||||||
|
|
||||||
|
**Explicitly forbidden** (evaluator rejects at parse time):
|
||||||
|
- Function calls other than `len()` and `None` tests
|
||||||
|
- Attribute access on arbitrary Python objects
|
||||||
|
- String interpolation or formatting
|
||||||
|
- Any control flow (`if`, `for`, `while`, `lambda`)
|
||||||
|
- Import statements
|
||||||
|
- Assignments
|
||||||
|
|
||||||
|
**Design rationale**: the expression language is intentionally small. Anything
|
||||||
|
complex enough to need more than this belongs in an Instruction, not a Rule.
|
||||||
|
When a rule condition becomes difficult to express, that is a signal that the
|
||||||
|
case requires LLM judgement, not a signal that the DSL needs more features.
|
||||||
|
|
||||||
|
#### Actions
|
||||||
|
|
||||||
|
A Rule's action block specifies:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action:
|
||||||
|
task_template: tasks/{template-slug}.md # required
|
||||||
|
target_repo: event.attributes.repo_slug # expression — attribute access only
|
||||||
|
priority: high # high | medium | low | literal
|
||||||
|
labels: ["onboarding", "security"] # literal list
|
||||||
|
due_in_days: 7 # optional, integer literal
|
||||||
|
```
|
||||||
|
|
||||||
|
`target_repo` and similar fields accept simple attribute access expressions
|
||||||
|
(no boolean logic — just path traversal). This allows dynamic routing to the
|
||||||
|
correct issue-core instance without arbitrary expression evaluation in action
|
||||||
|
fields.
|
||||||
|
|
||||||
|
#### Evaluation semantics
|
||||||
|
|
||||||
|
- All rules in an ActivityDefinition are evaluated; **all matching rules fire**
|
||||||
|
(not first-match-only). There is no implicit ordering beyond the file order,
|
||||||
|
which is documented in the ActivityDefinition for human clarity.
|
||||||
|
- A rule whose condition raises an error during evaluation is skipped and logged
|
||||||
|
as `rule_error`; other rules still fire. This prevents a single malformed rule
|
||||||
|
from silencing an entire ActivityDefinition.
|
||||||
|
- An empty condition (omitted `condition` field) evaluates to `true` — the rule
|
||||||
|
always fires when the trigger fires.
|
||||||
|
|
||||||
|
### Instructions
|
||||||
|
|
||||||
|
An Instruction defers the task-creation decision to an LLM. It specifies what
|
||||||
|
context to provide, how to frame the prompt, and what output schema to enforce.
|
||||||
|
|
||||||
|
#### Structure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# in an instruction fenced block:
|
||||||
|
id: {slug}
|
||||||
|
condition: '{expression}' # optional pre-filter (Rule DSL); runs before LLM
|
||||||
|
trusted_fields: # REQUIRED — explicit allowlist of payload fields
|
||||||
|
- event.attributes.repo_slug # safe to interpolate into prompt
|
||||||
|
- event.attributes.domain
|
||||||
|
- event.attributes.tags
|
||||||
|
model: claude-sonnet-4-6
|
||||||
|
review_required: false # true | false — curator gate for output
|
||||||
|
prompt: |
|
||||||
|
{prompt template — only trusted_fields may be interpolated}
|
||||||
|
output_schema: {path to JSON schema file}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Trusted fields and prompt injection protection
|
||||||
|
|
||||||
|
The `trusted_fields` list is **required** and enforced at parse time. Any field
|
||||||
|
not listed is unavailable to the prompt template. The template engine raises
|
||||||
|
`UntrustedFieldError` if the prompt references a field not in `trusted_fields`.
|
||||||
|
|
||||||
|
The rationale: event payloads may contain free-text from untrusted sources —
|
||||||
|
commit messages, issue titles, CVE descriptions, repo descriptions. Interpolating
|
||||||
|
these directly into a prompt creates a prompt injection surface. Trusted fields
|
||||||
|
are those whose values are validated by the event type schema (typed attributes
|
||||||
|
like slugs, domain names, tag lists) and cannot carry arbitrary instruction text
|
||||||
|
by construction.
|
||||||
|
|
||||||
|
Fields of type `object` (freeform JSON) are **never eligible** for `trusted_fields`
|
||||||
|
even if listed — the evaluator rejects this at parse time.
|
||||||
|
|
||||||
|
#### Output schema enforcement
|
||||||
|
|
||||||
|
The LLM response is validated against `output_schema` using JSON Schema validation.
|
||||||
|
If validation fails, the instruction retries once with the schema error appended
|
||||||
|
to the prompt. If the second attempt also fails, the instruction records an
|
||||||
|
`instruction_output_error` audit event and emits no tasks. Tasks are **never
|
||||||
|
created from unvalidated output**.
|
||||||
|
|
||||||
|
Structured output mode (tool_use / JSON mode) is used where the model supports
|
||||||
|
it. The output schema must define `List[TaskSpec]` or a compatible envelope.
|
||||||
|
|
||||||
|
#### `review_required: true`
|
||||||
|
|
||||||
|
When set, the instruction's proposed task list is written to a **pending review
|
||||||
|
queue** in issue-core rather than directly created. A human or curator agent
|
||||||
|
reviews and approves/rejects before tasks are materialised. This is the default
|
||||||
|
for instructions that create high-impact tasks (cross-repo changes, security
|
||||||
|
responses, production operations).
|
||||||
|
|
||||||
|
#### Evaluation semantics
|
||||||
|
|
||||||
|
- Instructions are evaluated **after** all rules in the ActivityDefinition.
|
||||||
|
- The optional `condition` field on an instruction uses the same Rule DSL as
|
||||||
|
a first-pass filter — if the condition is false, the LLM is not called.
|
||||||
|
This avoids LLM cost for events that clearly do not need instruction judgement.
|
||||||
|
- Instructions are **not** first-match-only; all instructions whose conditions
|
||||||
|
pass fire. An ActivityDefinition may have zero instructions.
|
||||||
|
|
||||||
|
### Audit trail
|
||||||
|
|
||||||
|
Every task emission records:
|
||||||
|
|
||||||
|
| Field | Rule | Instruction |
|
||||||
|
|---|---|---|
|
||||||
|
| `source_type` | `"rule"` | `"instruction"` |
|
||||||
|
| `source_id` | rule `id` from definition | instruction `id` from definition |
|
||||||
|
| `source_version` | ActivityDefinition version | ActivityDefinition version |
|
||||||
|
| `triggering_event_id` | event UUID | event UUID |
|
||||||
|
| `condition_matched` | expression string | expression string (pre-filter) |
|
||||||
|
| `prompt_hash` | — | SHA-256 of rendered prompt |
|
||||||
|
| `model` | — | model ID used |
|
||||||
|
| `output_validated` | — | `true` / `false` |
|
||||||
|
| `review_required` | — | `true` / `false` |
|
||||||
|
|
||||||
|
The audit trail is written to the `task_spawn_log` table in activity-core's database
|
||||||
|
and referenced from the task record in issue-core.
|
||||||
|
|
||||||
|
### Testing strategy
|
||||||
|
|
||||||
|
**Rules**: every rule can and should be unit-tested with fixture event payloads.
|
||||||
|
A test helper `evaluate_rule(condition_str, event_fixture)` returns `bool` and
|
||||||
|
raises on syntax errors. Tests live alongside ActivityDefinition files:
|
||||||
|
`activity-definitions/{slug}.test.json` — a list of `{event, expected_rules_fired}`
|
||||||
|
fixtures.
|
||||||
|
|
||||||
|
**Instructions**: instructions cannot be deterministically unit-tested. Instead:
|
||||||
|
- Sample evaluations are collected: given a fixture event, record the LLM response.
|
||||||
|
- Samples are committed to `activity-definitions/{slug}.samples/` for human review.
|
||||||
|
- Output schema validation is unit-tested independently of the LLM call.
|
||||||
|
- Prompt injection resistance is tested by including injection strings in fixture
|
||||||
|
event payloads and asserting they do not appear in the rendered prompt.
|
||||||
|
|
||||||
|
### rules-core module boundary
|
||||||
|
|
||||||
|
The rule evaluator and instruction executor live in `src/activity_core/rules/`.
|
||||||
|
Within this module:
|
||||||
|
|
||||||
|
- **No imports from** `temporalio`, `sqlalchemy`, `fastapi`, or any activity-core
|
||||||
|
application code.
|
||||||
|
- Public surface: `evaluate_condition(expr: str, event: EventEnvelope, context: dict) -> bool`
|
||||||
|
and `execute_instruction(instr: InstructionDef, event: EventEnvelope, context: dict) -> List[TaskSpec]`.
|
||||||
|
- The module is independently importable and testable without starting the Temporal
|
||||||
|
worker or Postgres.
|
||||||
|
|
||||||
|
This boundary makes future extraction to `rules-core` a packaging exercise, not a refactor.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The `ActivityDefinition` Pydantic model gains `rules: List[RuleDef]` and
|
||||||
|
`instructions: List[InstructionDef]` fields. The current implicit "always create
|
||||||
|
tasks" behaviour is replaced by explicit rule blocks.
|
||||||
|
- A new `RuleEvaluator` class (AST walker) is added to `src/activity_core/rules/`.
|
||||||
|
- A new `InstructionExecutor` class handles prompt rendering, LLM call, output
|
||||||
|
validation, and review queue routing.
|
||||||
|
- Integration tests for rule evaluation use fixture JSON; no running Temporal required.
|
||||||
|
- The `task_spawn_log` table is added to the Postgres schema (new Alembic migration).
|
||||||
|
- ActivityDefinition files that omit both `rules` and `instructions` are valid
|
||||||
|
(they fire with no output) — this supports future placeholder definitions.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**OPA / Rego for rule conditions**: powerful, well-established policy language,
|
||||||
|
supports complex logic. Rejected — Rego's learning curve is high for non-specialists;
|
||||||
|
agents rarely produce correct Rego without fine-tuning; it adds a runtime dependency.
|
||||||
|
The simple AST-walker DSL covers the realistic condition complexity for this org.
|
||||||
|
|
||||||
|
**Rules as Python lambdas**: maximum expressiveness. Rejected — arbitrary code
|
||||||
|
execution in a rule condition is a serious security surface, especially in an
|
||||||
|
org-wide event loop. Code deployment required for any rule change; agents cannot
|
||||||
|
write rules without code write access.
|
||||||
|
|
||||||
|
**LLM for all conditions (no Rule/Instruction split)**: simpler model, more
|
||||||
|
flexible. Rejected — non-deterministic for cases that are deterministic; expensive
|
||||||
|
for high-frequency events like cron ticks; impossible to unit-test; audit trail
|
||||||
|
for deterministic rules becomes murky.
|
||||||
|
|
||||||
|
**Instructions only, no Rules**: allows arbitrary LLM judgement for everything.
|
||||||
|
Rejected — LLM cost for every event, latency, and non-determinism are unacceptable
|
||||||
|
for high-frequency maintenance automations. Many cases (SBOM staleness check,
|
||||||
|
tag-based routing) are fully deterministic and should stay that way.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- ACT-ADR-001 — Event Bridge Architecture
|
||||||
|
- ACT-ADR-002 — Definition format (where rule/instruction blocks live)
|
||||||
|
- CUST-TFE-SCOPE-2026-000001 — task-flow-engine extraction (analogue pattern)
|
||||||
|
- `src/activity_core/rules/` — implementation home
|
||||||
Reference in New Issue
Block a user