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
repo: activity-core
updated: "2026-05-14"
updated: "2026-06-03"
---
# SCOPE
@@ -52,11 +52,17 @@ The two evaluation modes:
- **Context resolution adapters**: repo-scoping (repository capability queries),
state hub (domain and workstream state), extensible for other sources.
- **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
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
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,
triggering event id, model and prompt hash (instructions), issue-core task ref.
- **Webhook receiver**: HTTP endpoint normalising inbound Gitea/GitHub webhook
@@ -111,16 +117,57 @@ The two evaluation modes:
## Current State
- **Status**: active — WP-0001 (Foundation) and WP-0002 (Triggers & Ops) complete.
- **Implementation**: core is functional. `RunActivityWorkflow`, `TaskExecutorWorkflow`
(stub), PostgreSQL schema (activity_definitions, activity_runs, task_instances),
Temporal Schedules (cron), NATS Event Router, FastAPI admin API, Prometheus
metrics, and operational runbook are all implemented.
- **Next**: WP-0003 — event type registry, rule/instruction model, task emission
adapter, webhook receiver, one-off `scheduled` trigger type, INTENT.md and
SCOPE.md rewrite (this file). Architecture established in ACT-ADR-001/002/003.
- **Stability**: core workflow is stable; the rule/instruction layer and registry
are not yet implemented.
- **Status**: active production-backed service. Foundation, triggers/ops,
event bridge, Railiance deployment, and the production service workplans are
complete. The stale March WP-0002 handoff note has been reconciled and
archived.
- **Implementation**: core is functional. `RunActivityWorkflow`,
`TaskExecutorWorkflow` (stub), PostgreSQL schema, Temporal Schedules, NATS
Event Router, FastAPI admin API, Prometheus metrics, event type registry,
markdown ActivityDefinition parser/sync, rule evaluator, instruction
executor, context resolvers, issue sink, report sinks, Kubernetes deployment,
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
[activity-core] ← event type registry, rule evaluator, instruction executor
[issue-core] ← task lifecycle, assignment, tracking (Gitea / SQLite / GitHub)
[repos/services] ← execution: actual code changes, scans, operations
[activity-core] [issue-core] → [repos/services]
[activity-core] → [report sinks]
```
- **Upstream**: NATS (event bus), Temporal (durable workflow engine), PostgreSQL
(definitions and audit log), repo-scoping (context adapter), state hub (context
adapter and event publisher).
- **Downstream**: issue-core (task management). Agents and humans pick up tasks
from issue-core and do the actual work.
- **Downstream**: issue-core (task management) and configured report sinks.
Agents and humans pick up tasks from issue-core and do the actual work.
- **Coordinates with**: the state hub delegates maintenance automations to
activity-core by publishing lifecycle events; activity-core never writes to
the state hub directly.
activity-core by publishing lifecycle events or by being resolved as context.
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/schedule_manager.py` (Temporal Schedules),
`src/activity_core/api.py` (FastAPI admin).
- Definition files (WP-0003): `event-types/` and `activity-definitions/`
(not yet created — coming in WP-0003).
- Definition files: `event-types/`, `activity-definitions/`, and `tasks/`.
- Dev environment: `docker-compose.dev.yml` (Temporal + PostgreSQL + NATS).
- Entry points: `uv run python -m activity_core.worker` (Temporal worker),
`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
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:
task_template: tasks/sbom-rescan.md
target_repo: context.repos.repo_slug
task_template: Run SBOM rescan for {context.repo.repo_slug}
target_repo: context.repo.repo_slug
priority: medium
labels: ["sbom", "security", "automated"]
```
NOTE: in the production bulk-mode resolver path the condition matches against
the **worst** repo's age (the resolver hoists the worst entry's
`sbom_age_days`, `repo_slug`, `last_sbom_at`, `has_sbom` to the top of
`context.repos` alongside the per-repo list and summary counts). The rule
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`.
The bulk resolver exposes the per-repo entries under `context.repos.repos`.
The rule uses explicit `for_each` binding so the workflow evaluates the
condition once per repository and emits one task per stale repo. Action fields
may reference the bound item with `context.repo.*`.

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.orm import ActivityDefinition as ActivityDefinitionRow
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.models import InstructionDef
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
@@ -241,9 +241,8 @@ async def persist_task_instance(task_payload: dict) -> str:
@activity.defn
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.
Expected keys in payload:
@@ -268,18 +267,16 @@ async def evaluate_rules(payload: dict) -> list[dict]:
event_obj = _Env(event_attrs)
matched: list[dict] = []
task_specs: list[dict] = []
for rule in rules:
condition = rule.get("condition", "")
try:
if evaluate_condition(condition, event_obj, context):
matched.append(rule)
task_specs.extend(expand_rule_actions([rule], event_obj, context))
except UnsafeExpression as exc:
activity.logger.warning("rule %r unsafe expression — skipping: %s", rule.get("id"), exc)
except Exception as exc:
activity.logger.warning("rule %r eval error — skipping: %s", rule.get("id"), exc)
return matched
return task_specs
@activity.defn

View File

@@ -92,6 +92,14 @@ class ActionDef(BaseModel):
class RuleDef(BaseModel):
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(
default="",
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:
pass
matched_rules: list[dict] = await workflow.execute_activity(
task_spec_dicts: list[dict] = await workflow.execute_activity(
evaluate_rules,
{
"rules": defn.get("rules", []),
@@ -125,22 +125,6 @@ class RunActivityWorkflow:
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] = []
if defn.get("instructions"):
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.issue_sink import NullSink
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
_DEFINITIONS_DIR = Path(__file__).parent.parent / "activity-definitions"
@@ -59,27 +59,24 @@ def _run_rule_pipeline(
spawn_log: list[dict] = []
triggering_event_id = str(uuid.uuid4())
for repo in repos:
context = {"repos": repo}
if not evaluate_condition(rule["condition"], event, context):
continue
action = rule.get("action", {})
context = {"repos": {"repos": repos}}
for spec_dict in expand_rule_actions([rule], event, context):
spec = TaskSpec(
title=f"Run SBOM rescan — {repo['repo_slug']}",
description="SBOM rescan needed — age threshold exceeded.",
target_repo=repo["repo_slug"],
priority=action.get("priority", "medium"),
labels=action.get("labels", []),
title=spec_dict["title"],
description=spec_dict["description"],
target_repo=spec_dict["target_repo"],
priority=spec_dict["priority"],
labels=spec_dict["labels"],
due_in_days=spec_dict["due_in_days"],
source_type="rule",
source_id=rule["id"],
source_id=spec_dict["source_id"],
triggering_event_id=triggering_event_id,
)
ref = sink.emit(spec)
task_refs.append(ref)
spawn_log.append({
"source_id": rule["id"],
"condition_matched": rule["condition"],
"source_id": spec_dict["source_id"],
"condition_matched": spec_dict["condition"],
"triggering_event_id": triggering_event_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
entry = spawn_log[0]
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"]

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"
domain: custodian
repo: activity-core
status: ready
status: finished
owner: custodian
topic_slug: custodian
created: "2026-06-01"
updated: "2026-06-01"
updated: "2026-06-03"
state_hub_workstream_id: "36162ff0-9b47-47c4-8602-56767f9b7a1c"
---
@@ -112,7 +112,7 @@ worst-repo fields to the top of `context.repos`).
```task
id: ADHOC-2026-06-01-T02
status: todo
status: done
priority: low
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
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
```task

View File

@@ -1,11 +1,19 @@
---
type: session-note
created: "2026-03-28"
status: handoff
updated: "2026-06-03"
status: archived
---
# 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
Implementing custodian-WP-0002 (Triggers & Ops). Work interrupted on workstation