Expand rule actions for per-repo tasks

Add safe action interpolation and for_each binding for rule fan-out, update the weekly SBOM definition, cover the new evaluation path, and reconcile activity-core scope/workplans for the State Hub sync.
This commit is contained in:
2026-06-03 11:58:24 +02:00
parent 4b4e162c44
commit 30598fd1ad
12 changed files with 619 additions and 81 deletions

View File

@@ -1,7 +1,7 @@
--- ---
domain: capabilities domain: capabilities
repo: activity-core repo: activity-core
updated: "2026-05-14" updated: "2026-06-03"
--- ---
# SCOPE # SCOPE
@@ -52,11 +52,17 @@ The two evaluation modes:
- **Context resolution adapters**: repo-scoping (repository capability queries), - **Context resolution adapters**: repo-scoping (repository capability queries),
state hub (domain and workstream state), extensible for other sources. state hub (domain and workstream state), extensible for other sources.
- **Rule evaluator**: sandboxed AST walker for Python-like boolean expressions - **Rule evaluator**: sandboxed AST walker for Python-like boolean expressions
over event attributes and resolved context. No `exec()`. over event attributes and resolved context. Rule actions support safe
`context.*` / `event.*` interpolation and explicit `for_each` per-item
binding. No `exec()`.
- **Instruction executor**: trusted-field prompt rendering, LLM call via - **Instruction executor**: trusted-field prompt rendering, LLM call via
llm-connect, structured output validation, optional curator review queue. llm-connect, structured output validation, optional curator review queue,
and deterministic report sinks.
- **Task emission adapter**: abstraction over issue-core; current transport is - **Task emission adapter**: abstraction over issue-core; current transport is
REST; designed to migrate to NATS subscription without code changes. REST; designed to migrate to NATS subscription without code changes.
- **Report sinks**: instruction report outputs can be persisted to bounded
local working memory and posted as State Hub progress events. These are
reporting outputs, not task lifecycle ownership.
- **Spawn audit log**: every task emission recorded with rule/instruction id, - **Spawn audit log**: every task emission recorded with rule/instruction id,
triggering event id, model and prompt hash (instructions), issue-core task ref. triggering event id, model and prompt hash (instructions), issue-core task ref.
- **Webhook receiver**: HTTP endpoint normalising inbound Gitea/GitHub webhook - **Webhook receiver**: HTTP endpoint normalising inbound Gitea/GitHub webhook
@@ -111,16 +117,57 @@ The two evaluation modes:
## Current State ## Current State
- **Status**: active — WP-0001 (Foundation) and WP-0002 (Triggers & Ops) complete. - **Status**: active production-backed service. Foundation, triggers/ops,
- **Implementation**: core is functional. `RunActivityWorkflow`, `TaskExecutorWorkflow` event bridge, Railiance deployment, and the production service workplans are
(stub), PostgreSQL schema (activity_definitions, activity_runs, task_instances), complete. The stale March WP-0002 handoff note has been reconciled and
Temporal Schedules (cron), NATS Event Router, FastAPI admin API, Prometheus archived.
metrics, and operational runbook are all implemented. - **Implementation**: core is functional. `RunActivityWorkflow`,
- **Next**: WP-0003 — event type registry, rule/instruction model, task emission `TaskExecutorWorkflow` (stub), PostgreSQL schema, Temporal Schedules, NATS
adapter, webhook receiver, one-off `scheduled` trigger type, INTENT.md and Event Router, FastAPI admin API, Prometheus metrics, event type registry,
SCOPE.md rewrite (this file). Architecture established in ACT-ADR-001/002/003. markdown ActivityDefinition parser/sync, rule evaluator, instruction
- **Stability**: core workflow is stable; the rule/instruction layer and registry executor, context resolvers, issue sink, report sinks, Kubernetes deployment,
are not yet implemented. and operational runbook are all implemented.
- **Operational proof**: the daily State Hub WSJF triage cutover has completed
far enough that activity-core is now the trusted scheduled substrate for the
routine report. Recent hardening fixed the State Hub SBOM resolver contract,
made slow LLM activity timeouts configurable, and added safe rule action
interpolation plus explicit `for_each` binding for per-repo SBOM staleness
tasks.
- **Stability**: construction risk has shifted to operational hardening risk.
The full test suite passed on 2026-06-03 (`125 passed, 1 skipped`). The
remaining work is mostly observability, status-canon adaptation, contract
documentation, and broader production adoption rather than first
implementation.
- **Next**: `ACTIVITY-WP-0006` — post-triage operational hardening and scope
alignment.
---
## Assessment Against Intent
activity-core now matches the core intent: it answers **when** coordination
work should happen, **what** work should be created from current org context,
and **where** each work item should land. The daily WSJF triage is the clearest
judgement-oriented proof point; weekly SBOM staleness is the clearest
deterministic-rule proof point.
The governing boundary still matters. activity-core should keep owning trigger
durability, context resolution, rule/instruction evaluation, report/task
emission, and spawn/report audit. It should not become the task lifecycle
database, the project planner, or a general execution worker. The local
`TaskExecutorWorkflow` remains a stub and should stay that way unless a future
workplan explicitly rehomes execution responsibility.
One boundary nuance is now explicit: activity-core may post State Hub progress
events as a configured report sink. That is acceptable because it records the
result of an activity-core activation; it is not ownership of State Hub state,
task lifecycle, or workstream planning.
The main drift risk is convenience creep: adding direct task tracking,
project-phase state, or bespoke operational scripts because the Temporal
substrate is already nearby. Future work should prefer declarative
ActivityDefinitions, bounded context resolvers, and outbound adapters over
new one-off control paths.
--- ---
@@ -130,20 +177,19 @@ The two evaluation modes:
[NATS JetStream] ← publishers: state hub, Gitea webhooks, Temporal signals, cron [NATS JetStream] ← publishers: state hub, Gitea webhooks, Temporal signals, cron
[activity-core] ← event type registry, rule evaluator, instruction executor [activity-core] ← event type registry, rule evaluator, instruction executor
[activity-core] [issue-core] → [repos/services]
[issue-core] ← task lifecycle, assignment, tracking (Gitea / SQLite / GitHub) [activity-core] → [report sinks]
[repos/services] ← execution: actual code changes, scans, operations
``` ```
- **Upstream**: NATS (event bus), Temporal (durable workflow engine), PostgreSQL - **Upstream**: NATS (event bus), Temporal (durable workflow engine), PostgreSQL
(definitions and audit log), repo-scoping (context adapter), state hub (context (definitions and audit log), repo-scoping (context adapter), state hub (context
adapter and event publisher). adapter and event publisher).
- **Downstream**: issue-core (task management). Agents and humans pick up tasks - **Downstream**: issue-core (task management) and configured report sinks.
from issue-core and do the actual work. Agents and humans pick up tasks from issue-core and do the actual work.
- **Coordinates with**: the state hub delegates maintenance automations to - **Coordinates with**: the state hub delegates maintenance automations to
activity-core by publishing lifecycle events; activity-core never writes to activity-core by publishing lifecycle events or by being resolved as context.
the state hub directly. activity-core may post progress events as report outputs, but it does not own
State Hub task/workstream state.
--- ---
@@ -203,8 +249,7 @@ The two evaluation modes:
`src/activity_core/event_router.py` (NATS → Temporal), `src/activity_core/event_router.py` (NATS → Temporal),
`src/activity_core/schedule_manager.py` (Temporal Schedules), `src/activity_core/schedule_manager.py` (Temporal Schedules),
`src/activity_core/api.py` (FastAPI admin). `src/activity_core/api.py` (FastAPI admin).
- Definition files (WP-0003): `event-types/` and `activity-definitions/` - Definition files: `event-types/`, `activity-definitions/`, and `tasks/`.
(not yet created — coming in WP-0003).
- Dev environment: `docker-compose.dev.yml` (Temporal + PostgreSQL + NATS). - Dev environment: `docker-compose.dev.yml` (Temporal + PostgreSQL + NATS).
- Entry points: `uv run python -m activity_core.worker` (Temporal worker), - Entry points: `uv run python -m activity_core.worker` (Temporal worker),
`uv run uvicorn activity_core.api:app --port 8010` (admin API). `uv run uvicorn activity_core.api:app --port 8010` (admin API).

View File

@@ -28,21 +28,17 @@ SBOM staleness and flags any repository whose SBOM is older than 30 days.
```rule ```rule
id: flag-stale-sbom id: flag-stale-sbom
condition: 'context.repos.sbom_age_days > 30' for_each: context.repos.repos
bind_as: repo
condition: 'context.repo.sbom_age_days > 30'
action: action:
task_template: tasks/sbom-rescan.md task_template: Run SBOM rescan for {context.repo.repo_slug}
target_repo: context.repos.repo_slug target_repo: context.repo.repo_slug
priority: medium priority: medium
labels: ["sbom", "security", "automated"] labels: ["sbom", "security", "automated"]
``` ```
NOTE: in the production bulk-mode resolver path the condition matches against The bulk resolver exposes the per-repo entries under `context.repos.repos`.
the **worst** repo's age (the resolver hoists the worst entry's The rule uses explicit `for_each` binding so the workflow evaluates the
`sbom_age_days`, `repo_slug`, `last_sbom_at`, `has_sbom` to the top of condition once per repository and emits one task per stale repo. Action fields
`context.repos` alongside the per-repo list and summary counts). The rule may reference the bound item with `context.repo.*`.
therefore fires at most once per workflow run, not once per stale repo. The
aspirational per-stale-repo task fan-out is exercised by the integration
tests' simulated pipeline but is not delivered by the current workflow —
landing it requires (a) per-iteration context binding in the workflow and
(b) `context.*` interpolation in rule action fields. Both are tracked as
`ADHOC-2026-06-01-T02`.

View File

@@ -24,10 +24,10 @@ from activity_core.db import make_engine
from activity_core.issue_sink import get_issue_sink from activity_core.issue_sink import get_issue_sink
from activity_core.orm import ActivityDefinition as ActivityDefinitionRow from activity_core.orm import ActivityDefinition as ActivityDefinitionRow
from activity_core.orm import ActivityRun, TaskInstance, TaskSpawnLog from activity_core.orm import ActivityRun, TaskInstance, TaskSpawnLog
from activity_core.rules import evaluate_condition
from activity_core.llm_client import get_llm_client from activity_core.llm_client import get_llm_client
from activity_core.models import InstructionDef from activity_core.models import InstructionDef
from activity_core.report_sinks import persist_reports from activity_core.report_sinks import persist_reports
from activity_core.rules.actions import expand_rule_actions
from activity_core.rules.executor import execute_instruction_with_audit from activity_core.rules.executor import execute_instruction_with_audit
@@ -241,9 +241,8 @@ async def persist_task_instance(task_payload: dict) -> str:
@activity.defn @activity.defn
async def evaluate_rules(payload: dict) -> list[dict]: async def evaluate_rules(payload: dict) -> list[dict]:
"""Evaluate each rule condition against the event and context. """Evaluate rules and render matching actions as task specs.
Returns the list of matching rule dicts (those whose condition is True).
Rules that raise UnsafeExpression or any other error are skipped and logged. Rules that raise UnsafeExpression or any other error are skipped and logged.
Expected keys in payload: Expected keys in payload:
@@ -268,18 +267,16 @@ async def evaluate_rules(payload: dict) -> list[dict]:
event_obj = _Env(event_attrs) event_obj = _Env(event_attrs)
matched: list[dict] = [] task_specs: list[dict] = []
for rule in rules: for rule in rules:
condition = rule.get("condition", "")
try: try:
if evaluate_condition(condition, event_obj, context): task_specs.extend(expand_rule_actions([rule], event_obj, context))
matched.append(rule)
except UnsafeExpression as exc: except UnsafeExpression as exc:
activity.logger.warning("rule %r unsafe expression — skipping: %s", rule.get("id"), exc) activity.logger.warning("rule %r unsafe expression — skipping: %s", rule.get("id"), exc)
except Exception as exc: except Exception as exc:
activity.logger.warning("rule %r eval error — skipping: %s", rule.get("id"), exc) activity.logger.warning("rule %r eval error — skipping: %s", rule.get("id"), exc)
return matched return task_specs
@activity.defn @activity.defn

View File

@@ -92,6 +92,14 @@ class ActionDef(BaseModel):
class RuleDef(BaseModel): class RuleDef(BaseModel):
id: str id: str
for_each: str | None = Field(
default=None,
description="Optional event/context path to a list for per-item rule expansion.",
)
bind_as: str = Field(
default="item",
description="Context key used for each item when for_each is set.",
)
condition: str = Field( condition: str = Field(
default="", default="",
description="Rule DSL expression; empty string means always true.", description="Rule DSL expression; empty string means always true.",

View File

@@ -0,0 +1,153 @@
"""Rule action expansion into concrete task specs.
Boundary: no imports from temporalio, sqlalchemy, fastapi, or any
activity_core.* module outside rules/.
"""
from __future__ import annotations
import re
from dataclasses import asdict
from typing import Any
from activity_core.rules.evaluator import UnsafeExpression, evaluate_condition
from activity_core.rules.models import TaskSpec
_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}")
_PATH_RE = re.compile(r"^(event|context)(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$")
def expand_rule_actions(rules: list[dict], event: Any, context: dict) -> list[dict]:
"""Evaluate rule conditions and render matching actions as TaskSpec dicts.
A rule can opt into per-item expansion with ``for_each``:
for_each: context.repos.repos
bind_as: repo
Each list item is then available as ``context.repo`` while rendering the
condition and action fields. Without ``for_each``, a rule is evaluated once
against the original context.
"""
task_specs: list[dict] = []
for rule in rules:
for bound_context in _iteration_contexts(rule, event, context):
if not _condition_matches(rule, event, bound_context):
continue
task_specs.append(_task_spec_for_rule(rule, event, bound_context))
return task_specs
def _iteration_contexts(rule: dict, event: Any, context: dict) -> list[dict]:
for_each = rule.get("for_each")
if not for_each:
return [context]
if not isinstance(for_each, str) or not _PATH_RE.fullmatch(for_each):
raise UnsafeExpression(f"invalid for_each path: {for_each!r}")
values = _resolve_field(for_each, event, context)
if values is None:
return []
if not isinstance(values, list):
raise UnsafeExpression(f"for_each path does not resolve to a list: {for_each!r}")
bind_as = rule.get("bind_as", "item")
if not isinstance(bind_as, str) or not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", bind_as):
raise UnsafeExpression(f"invalid bind_as name: {bind_as!r}")
contexts: list[dict] = []
for value in values:
bound = dict(context)
bound[bind_as] = value
contexts.append(bound)
return contexts
def _condition_matches(rule: dict, event: Any, context: dict) -> bool:
return evaluate_condition(rule.get("condition", ""), event, context)
def _task_spec_for_rule(rule: dict, event: Any, context: dict) -> dict:
action = rule.get("action", {})
spec = TaskSpec(
title=str(_render_value(action.get("task_template", rule.get("id", "")), event, context) or ""),
description=str(_render_value(action.get("description", ""), event, context) or ""),
target_repo=_string_or_none(_render_value(action.get("target_repo"), event, context)),
priority=str(_render_value(action.get("priority", "medium"), event, context) or "medium"),
labels=_render_labels(action.get("labels", []), event, context),
due_in_days=_int_or_none(_render_value(action.get("due_in_days"), event, context)),
source_type="rule",
source_id=rule.get("id", ""),
)
result = asdict(spec)
result["condition"] = rule.get("condition", "")
return result
def _render_labels(value: Any, event: Any, context: dict) -> list[str]:
if not isinstance(value, list):
return []
rendered = []
for item in value:
rendered_item = _render_value(item, event, context)
if rendered_item is not None:
rendered.append(str(rendered_item))
return rendered
def _render_value(value: Any, event: Any, context: dict) -> Any:
if isinstance(value, str):
if _PATH_RE.fullmatch(value):
return _resolve_field(value, event, context)
if "{" in value and "}" in value:
return _PLACEHOLDER_RE.sub(
lambda match: _string_or_empty(
_resolve_field(match.group(1), event, context)
),
value,
)
return value
def _resolve_field(field_path: str, event: Any, context: dict) -> Any:
if not _PATH_RE.fullmatch(field_path):
raise UnsafeExpression(f"invalid field path: {field_path!r}")
root, tail = field_path.split(".", 1)
if root == "event":
return _resolve_path(event, tail)
return _resolve_path(context, tail)
def _resolve_path(obj: Any, path: str) -> Any:
current = obj
for part in path.split("."):
if current is None:
return None
if isinstance(current, dict):
current = current.get(part)
else:
current = getattr(current, part, None)
return current
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
return str(value)
def _string_or_empty(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (dict, list)):
raise UnsafeExpression("template placeholder resolved to a non-scalar value")
return str(value)
def _int_or_none(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError) as exc:
raise UnsafeExpression(f"field cannot be converted to int: {value!r}") from exc

View File

@@ -114,7 +114,7 @@ class RunActivityWorkflow:
except Exception: except Exception:
pass pass
matched_rules: list[dict] = await workflow.execute_activity( task_spec_dicts: list[dict] = await workflow.execute_activity(
evaluate_rules, evaluate_rules,
{ {
"rules": defn.get("rules", []), "rules": defn.get("rules", []),
@@ -125,22 +125,6 @@ class RunActivityWorkflow:
retry_policy=_RETRY_POLICY, retry_policy=_RETRY_POLICY,
) )
# Convert matched rules to TaskSpec dicts for emission.
task_spec_dicts: list[dict] = []
for rule in matched_rules:
action = rule.get("action", {})
task_spec_dicts.append({
"title": action.get("task_template", rule.get("id", "")),
"description": "",
"target_repo": action.get("target_repo"),
"priority": action.get("priority", "medium"),
"labels": action.get("labels", []),
"due_in_days": action.get("due_in_days"),
"source_type": "rule",
"source_id": rule.get("id", ""),
"condition": rule.get("condition", ""),
})
report_dicts: list[dict] = [] report_dicts: list[dict] = []
if defn.get("instructions"): if defn.get("instructions"):
instruction_result: dict = await workflow.execute_activity( instruction_result: dict = await workflow.execute_activity(

117
tests/rules/test_actions.py Normal file
View File

@@ -0,0 +1,117 @@
from __future__ import annotations
import pytest
from activity_core.rules.actions import expand_rule_actions
from activity_core.rules.evaluator import UnsafeExpression
class _Attrs:
def __init__(self, **kw):
for k, v in kw.items():
setattr(self, k, v)
class _Event:
def __init__(self, **attrs):
self.attributes = _Attrs(**attrs)
def test_action_field_path_interpolation_resolves_context_value() -> None:
rules = [
{
"id": "flag-stale-sbom",
"condition": "context.repos.sbom_age_days > 30",
"action": {
"task_template": "Run SBOM rescan for {context.repos.repo_slug}",
"target_repo": "context.repos.repo_slug",
"priority": "medium",
"labels": ["sbom", "{context.repos.repo_slug}"],
},
}
]
specs = expand_rule_actions(
rules,
_Event(),
{"repos": {"repo_slug": "activity-core", "sbom_age_days": 45}},
)
assert specs == [
{
"title": "Run SBOM rescan for activity-core",
"description": "",
"target_repo": "activity-core",
"priority": "medium",
"labels": ["sbom", "activity-core"],
"due_in_days": None,
"source_type": "rule",
"source_id": "flag-stale-sbom",
"triggering_event_id": "",
"activity_definition_id": "",
"condition": "context.repos.sbom_age_days > 30",
}
]
def test_for_each_binds_each_list_item_before_condition_and_action_rendering() -> None:
rules = [
{
"id": "flag-stale-sbom",
"for_each": "context.repos.repos",
"bind_as": "repo",
"condition": "context.repo.sbom_age_days > 30",
"action": {
"task_template": "Run SBOM rescan for {context.repo.repo_slug}",
"target_repo": "context.repo.repo_slug",
"priority": "medium",
"labels": ["sbom", "security", "automated"],
},
}
]
context = {
"repos": {
"repos": [
{"repo_slug": "repo-a", "sbom_age_days": 60},
{"repo_slug": "repo-b", "sbom_age_days": 10},
{"repo_slug": "repo-c", "sbom_age_days": 45},
]
}
}
specs = expand_rule_actions(rules, _Event(), context)
assert [spec["target_repo"] for spec in specs] == ["repo-a", "repo-c"]
assert [spec["title"] for spec in specs] == [
"Run SBOM rescan for repo-a",
"Run SBOM rescan for repo-c",
]
def test_for_each_rejects_non_path_expression() -> None:
rules = [
{
"id": "bad",
"for_each": "__import__('os')",
"condition": "",
"action": {"task_template": "bad"},
}
]
with pytest.raises(UnsafeExpression):
expand_rule_actions(rules, _Event(), {})
def test_template_placeholder_rejects_non_scalar_values() -> None:
rules = [
{
"id": "bad",
"condition": "",
"action": {
"task_template": "Run {context.repos}",
},
}
]
with pytest.raises(UnsafeExpression):
expand_rule_actions(rules, _Event(), {"repos": [{"repo_slug": "repo-a"}]})

View File

@@ -20,7 +20,7 @@ import pytest
from activity_core.definition_parser import parse_file from activity_core.definition_parser import parse_file
from activity_core.issue_sink import NullSink from activity_core.issue_sink import NullSink
from activity_core.models import EventEnvelope from activity_core.models import EventEnvelope
from activity_core.rules.evaluator import evaluate_condition from activity_core.rules.actions import expand_rule_actions
from activity_core.rules.models import TaskRef, TaskSpec from activity_core.rules.models import TaskRef, TaskSpec
_DEFINITIONS_DIR = Path(__file__).parent.parent / "activity-definitions" _DEFINITIONS_DIR = Path(__file__).parent.parent / "activity-definitions"
@@ -59,27 +59,24 @@ def _run_rule_pipeline(
spawn_log: list[dict] = [] spawn_log: list[dict] = []
triggering_event_id = str(uuid.uuid4()) triggering_event_id = str(uuid.uuid4())
for repo in repos: context = {"repos": {"repos": repos}}
context = {"repos": repo} for spec_dict in expand_rule_actions([rule], event, context):
if not evaluate_condition(rule["condition"], event, context):
continue
action = rule.get("action", {})
spec = TaskSpec( spec = TaskSpec(
title=f"Run SBOM rescan — {repo['repo_slug']}", title=spec_dict["title"],
description="SBOM rescan needed — age threshold exceeded.", description=spec_dict["description"],
target_repo=repo["repo_slug"], target_repo=spec_dict["target_repo"],
priority=action.get("priority", "medium"), priority=spec_dict["priority"],
labels=action.get("labels", []), labels=spec_dict["labels"],
due_in_days=spec_dict["due_in_days"],
source_type="rule", source_type="rule",
source_id=rule["id"], source_id=spec_dict["source_id"],
triggering_event_id=triggering_event_id, triggering_event_id=triggering_event_id,
) )
ref = sink.emit(spec) ref = sink.emit(spec)
task_refs.append(ref) task_refs.append(ref)
spawn_log.append({ spawn_log.append({
"source_id": rule["id"], "source_id": spec_dict["source_id"],
"condition_matched": rule["condition"], "condition_matched": spec_dict["condition"],
"triggering_event_id": triggering_event_id, "triggering_event_id": triggering_event_id,
"task_ref": ref.external_id, "task_ref": ref.external_id,
}) })
@@ -121,7 +118,7 @@ def test_pipeline_emits_one_task_for_stale_repo_only():
assert len(spawn_log) == 1 assert len(spawn_log) == 1
entry = spawn_log[0] entry = spawn_log[0]
assert entry["source_id"] == "flag-stale-sbom" assert entry["source_id"] == "flag-stale-sbom"
assert entry["condition_matched"] == "context.repos.sbom_age_days > 30" assert entry["condition_matched"] == "context.repo.sbom_age_days > 30"
assert entry["triggering_event_id"] == spawn_log[0]["triggering_event_id"] assert entry["triggering_event_id"] == spawn_log[0]["triggering_event_id"]

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import pytest
from activity_core import activities
@pytest.mark.asyncio
async def test_evaluate_rules_returns_interpolated_task_specs() -> None:
result = await activities.evaluate_rules({
"rules": [
{
"id": "flag-stale-sbom",
"for_each": "context.repos.repos",
"bind_as": "repo",
"condition": "context.repo.sbom_age_days > 30",
"action": {
"task_template": "Run SBOM rescan for {context.repo.repo_slug}",
"target_repo": "context.repo.repo_slug",
"priority": "medium",
"labels": ["sbom", "{context.repo.repo_slug}"],
},
}
],
"event": {},
"context": {
"repos": {
"repos": [
{"repo_slug": "fresh-repo", "sbom_age_days": 5},
{"repo_slug": "stale-repo", "sbom_age_days": 40},
]
}
},
})
assert len(result) == 1
assert result[0]["title"] == "Run SBOM rescan for stale-repo"
assert result[0]["target_repo"] == "stale-repo"
assert result[0]["labels"] == ["sbom", "stale-repo"]
assert result[0]["condition"] == "context.repo.sbom_age_days > 30"

View File

@@ -0,0 +1,167 @@
---
id: ACTIVITY-WP-0006
type: workplan
title: "Post-triage operational hardening"
domain: custodian
repo: activity-core
status: ready
owner: codex
topic_slug: custodian
created: "2026-06-03"
updated: "2026-06-03"
---
# ACTIVITY-WP-0006 — Post-triage operational hardening
## Context
activity-core has crossed the main construction threshold: Temporal-backed
schedules, context resolution, deterministic rules, LLM instructions, report
sinks, and the Railiance production service are implemented. The daily State
Hub WSJF triage cutover is now trusted enough that activity-core can be treated
as the standing scheduled substrate rather than an experiment.
The next work should keep that substrate dependable and aligned with
`INTENT.md`: activity-core owns when coordination work runs, what task/report
outputs are produced, and where they are emitted. It must not grow into the
task lifecycle database, a project planner, or an execution worker.
## Task Status Canon Adaptation
```task
id: ACTIVITY-WP-0006-T01
status: todo
priority: high
```
Adapt activity-core to State Hub's task status canon:
`wait`, `todo`, `progress`, `done`, `cancel`.
Scope:
- update `AGENTS.md` task-status examples and progression text
- update State Hub context resolver task-status filters and digest counters
- keep workstream/workplan lifecycle status separate; `blocked` remains valid
for workstreams/workplans where State Hub still uses it
- update tests that fixture or assert `in_progress` / task-level `blocked`
- resolve the State Hub interface-change notice only after the repo is adapted
Done when the full test suite passes and activity-core no longer depends on
legacy task-status aliases for State Hub API clients or tests.
## Daily Triage Observability Runbook
```task
id: ACTIVITY-WP-0006-T02
status: todo
priority: high
```
Document and, where cheap, automate how to answer "did today's daily triage
run happen?"
The operator should be able to check:
- Temporal schedule state and latest workflow history
- `activity_runs` row for the daily triage ActivityDefinition
- State Hub `daily_triage` progress event
- working-memory report note
- expected missed-run behavior (`skip`, not catch-up)
- the configured LLM and Temporal timeout relationship
Done when `docs/runbook.md` has a concise daily-triage verification section
and any helper command/script is covered by tests or a dry-run path.
## Three-Run Calibration Feedback
```task
id: ACTIVITY-WP-0006-T03
status: todo
priority: medium
```
Collect three consecutive scheduled activity-core daily triage runs and feed
the result back into the Custodian WSJF calibration loop.
Assess:
- whether the top recommendations matched actual useful follow-up work
- report length and density
- loose-end detection sensitivity
- stale-but-intentionally-parked work handling
- whether model settings or prompt/schema constraints need adjustment
Done when the calibration result is recorded in State Hub and the related
`CUST-WP-0044` / `CUST-WP-0045` tasks can close based on activity-core runs,
not Codex app fallback runs.
## Rule Action Contract Documentation
```task
id: ACTIVITY-WP-0006-T04
status: todo
priority: medium
```
Document the rule action contract introduced by the ADHOC-2026-06-01 work:
whole-field `context.*` / `event.*` paths, scalar `{context.foo}` placeholders,
and explicit `for_each` / `bind_as` per-item expansion.
Also decide and document the naming/semantics mismatch around
`action.task_template`: today it is the emitted task title field, while
`tasks/*.md` contains template files with their own title templates.
Done when ADR-003 or a focused follow-up doc contains examples, unsafe cases,
and the weekly SBOM staleness definition is cited as the canonical pattern.
## Production Alerting And Failure Modes
```task
id: ACTIVITY-WP-0006-T05
status: todo
priority: medium
```
Turn the current confidence in the daily triage schedule into routine
operational visibility.
Cover:
- Kubernetes/Temporal worker health expectations
- schedule paused/missing detection
- report sink failure behavior
- LLM timeout and retry behavior
- what should page, what should only leave a progress note, and what should be
handled in the next operator session
Done when the runbook and metrics/health surface make ordinary failures visible
without inspecting a Codex Desktop session.
## Issue-Core Emission Boundary Verification
```task
id: ACTIVITY-WP-0006-T06
status: todo
priority: medium
```
Verify the downstream task emission boundary now that rule fan-out is real.
Questions to close:
- which issue-core endpoint is authoritative for task creation in the current
environment
- whether `IssueCoreRestSink` should keep using REST or move to the intended
NATS subscription path
- whether emitted rule tasks carry enough title, description, labels,
source id, condition, and target repo data for issue-core and operators
- whether weekly SBOM staleness can be safely enabled against the real sink
Done when there is a tested or dry-run-verified path from a rule match to a
downstream task reference, and activity-core still owns only the spawn audit
trail, not task lifecycle state.
## Completion Criteria
- State Hub task-status canon adaptation is complete.
- Daily triage has an operator-grade verification path and three-run
calibration evidence.
- Rule action semantics are documented and no longer surprising.
- Production failure modes are observable enough for routine operation.
- Downstream task emission has been verified without expanding activity-core's
ownership boundary.

View File

@@ -4,11 +4,11 @@ type: workplan
title: "Ad hoc — activity-core opportunistic fixes 2026-06-01" title: "Ad hoc — activity-core opportunistic fixes 2026-06-01"
domain: custodian domain: custodian
repo: activity-core repo: activity-core
status: ready status: finished
owner: custodian owner: custodian
topic_slug: custodian topic_slug: custodian
created: "2026-06-01" created: "2026-06-01"
updated: "2026-06-01" updated: "2026-06-03"
state_hub_workstream_id: "36162ff0-9b47-47c4-8602-56767f9b7a1c" state_hub_workstream_id: "36162ff0-9b47-47c4-8602-56767f9b7a1c"
--- ---
@@ -112,7 +112,7 @@ worst-repo fields to the top of `context.repos`).
```task ```task
id: ADHOC-2026-06-01-T02 id: ADHOC-2026-06-01-T02
status: todo status: done
priority: low priority: low
state_hub_task_id: "6b3a185e-cbea-454c-82fb-8b4c16cefef0" state_hub_task_id: "6b3a185e-cbea-454c-82fb-8b4c16cefef0"
``` ```
@@ -168,6 +168,32 @@ expressions and a stale-repo workflow run emits a TaskSpec with the actual
repo slug, or (b) a recorded decision explicitly defers/declines the change repo slug, or (b) a recorded decision explicitly defers/declines the change
with reasoning. with reasoning.
**Completion — 2026-06-03:**
Implemented explicit rule action expansion in `activity_core.rules.actions`.
`evaluate_rules` now returns concrete TaskSpec dictionaries directly, and
`RunActivityWorkflow` no longer lifts raw YAML action fields itself.
Action fields support two safe interpolation forms:
- whole-field paths such as `target_repo: context.repo.repo_slug`
- scalar placeholders such as `task_template: Run SBOM rescan for {context.repo.repo_slug}`
Rules may opt into per-item binding with:
```yaml
for_each: context.repos.repos
bind_as: repo
condition: 'context.repo.sbom_age_days > 30'
```
`activity-definitions/weekly-sbom-staleness.md` now uses that explicit
contract, so bulk SBOM staleness evaluation emits one task per stale repo
instead of one task for the hoisted worst repo. Tests cover direct action
interpolation, `for_each` binding, activity-level rule evaluation, and the
weekly SBOM integration path.
Tests: `PYTHONPATH=src .venv/bin/python -m pytest -q` -> 125 passed, 1 skipped.
### T03 - Make activity-core's Temporal activity timeout env-configurable ### T03 - Make activity-core's Temporal activity timeout env-configurable
```task ```task

View File

@@ -1,11 +1,19 @@
--- ---
type: session-note type: session-note
created: "2026-03-28" created: "2026-03-28"
status: handoff updated: "2026-06-03"
status: archived
--- ---
# WP-0002 Handoff Note — Continue on CoulombCore # WP-0002 Handoff Note — Continue on CoulombCore
## Archive note — 2026-06-03
This handoff note has been reconciled and archived. Its remaining build order
is superseded by `custodian-WP-0002-triggers-ops.md`, which is marked done, and
by later completed workplans for the event bridge, Railiance operations, and
production service. It is no longer an active source of next steps.
## Context ## Context
Implementing custodian-WP-0002 (Triggers & Ops). Work interrupted on workstation Implementing custodian-WP-0002 (Triggers & Ops). Work interrupted on workstation