Implement ACTIVITY-WP-0022/0023: safe sink default and gap closures
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Build and Publish Container Image / build-and-push (push) Successful in 28s

Default ISSUE_SINK_TYPE to state-hub (no silent Forgejo issues), hard-fail
prune apply without live-images protection, refresh-live-images script,
disable TaskExecutor stub by default, and document consumer/sink contracts.
This commit is contained in:
tegwick 2026-07-21 21:40:08 +02:00
parent 5c7a90ce7c
commit 4f5399df84
19 changed files with 525 additions and 155 deletions

View file

@ -22,7 +22,8 @@ ISSUE_CORE_URL=http://127.0.0.1:8765
# Shared ingestion key — must match issue-core's ISSUE_CORE_API_KEY. # Shared ingestion key — must match issue-core's ISSUE_CORE_API_KEY.
ISSUE_CORE_API_KEY= ISSUE_CORE_API_KEY=
# Sink type: 'rest' (POST to issue-core) or 'null' (discard, for dry-run). # Sink type: 'rest' (POST to issue-core) or 'null' (discard, for dry-run).
ISSUE_SINK_TYPE=rest # state-hub (default, no Forgejo) | null (dry-run) | rest (issue-core opt-in)
ISSUE_SINK_TYPE=state-hub
# ── Activity definitions ─────────────────────────────────────────────────────── # ── Activity definitions ───────────────────────────────────────────────────────
# Colon-separated paths to additional activity-definitions/ directories. # Colon-separated paths to additional activity-definitions/ directories.

View file

@ -1,7 +1,7 @@
--- ---
domain: capabilities domain: capabilities
repo: activity-core repo: activity-core
updated: "2026-05-14" updated: "2026-07-21"
--- ---
# INTENT # INTENT
@ -63,10 +63,14 @@ It is an event loop governed by declarative rules and LLM instructions:
is needed to decide what tasks are appropriate. Both are defined as markdown is needed to decide what tasks are appropriate. Both are defined as markdown
files, co-located with their intent and debugging guidance (see ACT-ADR-002, files, co-located with their intent and debugging guidance (see ACT-ADR-002,
ACT-ADR-003). ACT-ADR-003).
- **Task emission**: the output of every activation is a set of task creation - **Task / outcome emission**: activations emit task specs and/or reports via a
requests sent to issue-core via a task emission adapter. activity-core records configured **sink**. Internal fleet findings default to State Hub progress
the spawn event (what was created, when, referencing the issue-core task ID) (`activity_task_spawn`) so they do not silently create Forgejo issues
as an audit trail — not as the authoritative task record. (ACTIVITY-WP-0022). Optional `ISSUE_SINK_TYPE=rest` projects to issue-core
when an external tracker issue is intentionally desired. activity-core
records the spawn event as an audit trail — not as the authoritative task
record. Downstream **executors** (per-repo workers, agent-harness) perform
the work.
--- ---
@ -74,7 +78,7 @@ It is an event loop governed by declarative rules and LLM instructions:
| Concern | Owner | | Concern | Owner |
|---|---| |---|---|
| Task lifecycle (create, assign, track, close) | issue-core | | Task lifecycle (create, assign, track, close) | issue-core / work-record connectors (not default for internal findings) |
| Project and initiative management | project-core (future) | | Project and initiative management | project-core (future) |
| Repository capability profiling | repo-scoping | | Repository capability profiling | repo-scoping |
| Cross-domain coordination state | state hub | | Cross-domain coordination state | state hub |
@ -123,7 +127,12 @@ and auditable.
**No task state ownership.** activity-core holds a spawn audit trail, not task **No task state ownership.** activity-core holds a spawn audit trail, not task
state. The moment it starts tracking whether tasks are complete, blocked, or state. The moment it starts tracking whether tasks are complete, blocked, or
re-assigned, it has become a task database — and that is issue-core's job. re-assigned, it has become a task database — that belongs to issue-core or
fleet work-record systems, not here.
**Safe sinks for internal findings.** Automated internal coordination must not
default to silent Forgejo/Gitea issues. Prefer State Hub progress / work-record
intake; reserve issue-core REST for explicit external projection.
**Publisher-declared event governance.** Producers of org events register their **Publisher-declared event governance.** Producers of org events register their
event types by committing definition files. Curator review is a configurable event types by committing definition files. Curator review is a configurable

View file

@ -43,6 +43,9 @@ automation-status-json: ## Report recent automation status as JSON
prod-automation-status: ## Prod run counts via SSH railiance01 (ACTIVITY-WP-0021) prod-automation-status: ## Prod run counts via SSH railiance01 (ACTIVITY-WP-0021)
./scripts/prod_automation_status.sh $(SINCE) ./scripts/prod_automation_status.sh $(SINCE)
refresh-live-images: ## Refresh multi-cluster Forgejo live-images protection list
./scripts/refresh_live_images.sh
automation-list: ## List configured scheduled automations from repo-owned definitions automation-list: ## List configured scheduled automations from repo-owned definitions
@uv run python scripts/automation_inventory.py --format "$(FORMAT)" --enabled "$(ENABLED)" $(if $(TRIGGER),--trigger-type "$(TRIGGER)",) $(if $(ACTIVITY_ID),--activity-id "$(ACTIVITY_ID)",) $(if $(ACTIVITY_NAME),--activity-name "$(ACTIVITY_NAME)",) @uv run python scripts/automation_inventory.py --format "$(FORMAT)" --enabled "$(ENABLED)" $(if $(TRIGGER),--trigger-type "$(TRIGGER)",) $(if $(ACTIVITY_ID),--activity-id "$(ACTIVITY_ID)",) $(if $(ACTIVITY_NAME),--activity-name "$(ACTIVITY_NAME)",)

View file

@ -1,7 +1,7 @@
--- ---
id: weekly-coding-retro id: weekly-coding-retro
name: Weekly Coding Retrospection name: Weekly Coding Retrospection
enabled: true enabled: false
owner: custodian-agent owner: custodian-agent
governance: custodian governance: custodian
status: proposed status: proposed

View file

@ -50,15 +50,16 @@ report_sinks:
topic_id: cee7bedf-2b48-46ef-8601-006474f2ad7a topic_id: cee7bedf-2b48-46ef-8601-006474f2ad7a
``` ```
Task emission for stale repos is **disabled** while IssueSink→Forgejo is Task emission uses the fleet default sink (`ISSUE_SINK_TYPE=state-hub`
policy/token broken (ACTIVITY-WP-0021). Re-enable the rule below once ACTIVITY-WP-0022): stale repos spawn `activity_task_spawn` progress events,
`ISSUE_SINK_TYPE=rest` (or `state-hub`) is proven healthy again. not Forgejo issues. The deterministic instruction always posts a
`sbom_staleness` summary for operators.
```rule ```rule
id: flag-stale-sbom id: flag-stale-sbom
for_each: context.repos.repos for_each: context.repos.repos
bind_as: repo bind_as: repo
condition: 'false' condition: 'context.repo.sbom_age_days > 30'
action: action:
task_template: Run SBOM rescan for {context.repo.repo_slug} task_template: Run SBOM rescan for {context.repo.repo_slug}
target_repo: context.repo.repo_slug target_repo: context.repo.repo_slug

View file

@ -18,7 +18,7 @@ extension point `af654abb`).
| Queue name | Registered workers | | Queue name | Registered workers |
|---|---| |---|---|
| `orchestrator-tq` | `RunActivityWorkflow` and all its activities (`load_activity_definition`, `resolve_context`, `log_run`) | | `orchestrator-tq` | `RunActivityWorkflow` and all its activities (`load_activity_definition`, `resolve_context`, `log_run`) |
| `task-execution-tq` | `TaskExecutorWorkflow` compatibility stub only; real execution belongs in per-repo workers | | `task-execution-tq` | Legacy `TaskExecutorWorkflow` stub only when `ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB=true`; real execution belongs in per-repo workers / agent-harness |
**Rule:** a workflow and its activities must be registered on the same task queue. **Rule:** a workflow and its activities must be registered on the same task queue.
Cross-queue activity calls require an explicit `task_queue` argument on Cross-queue activity calls require an explicit `task_queue` argument on

View file

@ -1,19 +1,30 @@
# Issue-Core Emission Boundary # Task emission boundary (IssueSink)
activity-core owns the decision to spawn a task and the audit trail that says activity-core owns the decision to spawn a task and the audit trail that says
why it spawned. It does not own downstream task lifecycle state after emission. why it spawned. It does not own downstream task lifecycle state after emission.
## Current authoritative endpoint ## Sink matrix (ACTIVITY-WP-0022)
The current authoritative boundary is the issue-core REST API: | `ISSUE_SINK_TYPE` | Destination | Default? |
| --- | --- | --- |
| **`state-hub`** | State Hub progress `activity_task_spawn` | **Yes** (safe for internal findings) |
| **`null`** | Synthetic `null-*` refs only | Dry-run / tests |
| **`rest`** | issue-core `POST /issues/` (may → Forgejo) | **Explicit opt-in only** |
Code default when unset: **`state-hub`** (`DEFAULT_ISSUE_SINK_TYPE`).
Unknown values fall back to `state-hub` and log a warning.
Consumer contract (fields, idempotency, Binky completion):
`docs/task-emission-consumer-contract.md`.
## REST endpoint (opt-in)
```text ```text
POST {ISSUE_CORE_URL}/issues/ POST {ISSUE_CORE_URL}/issues/
``` ```
`IssueCoreRestSink` authenticates with the shared `ISSUE_CORE_API_KEY` env var `IssueCoreRestSink` uses `ISSUE_CORE_API_KEY` via `Authorization: Bearer <key>`:
(same value as the issue-core server) via `Authorization: Bearer <key>` and
sends this payload:
```json ```json
{ {
@ -30,110 +41,57 @@ sends this payload:
} }
``` ```
The expected response contains `issue_id` and may include `issue_url` and Response: `issue_id` (required), optional `issue_url`, `backend`. Stored in
`backend`. activity-core stores only the returned task reference in `task_spawn_log` only as a reference.
`task_spawn_log`; issue-core remains authoritative for task status, assignment,
comments, closure, and cancellation. ## State Hub sink (default)
Each TaskSpec becomes a progress event (`activity_task_spawn` by default).
No Forgejo issue is created. Suitable for Binky rhythm, internal hygiene, and
any definition that should not spam trackers.
## REST versus NATS ## REST versus NATS
Keep REST as the active emission contract until issue-core publishes and owns a Keep REST as the issue-core transport until issue-core owns a durable NATS
durable NATS consumer for task-creation commands. NATS is still appropriate for command consumer. NATS remains appropriate for **event intake** into
event intake into activity-core, but task creation needs an acknowledged, activity-core.
idempotent command boundary. A future NATS sink must return or later correlate a
task reference before it can replace `IssueCoreRestSink`.
## Safe operating modes ## Operating modes
- `ISSUE_SINK_TYPE=null`: dry-run/audit mode. Task specs are rendered and the - **`state-hub` (default):** fleet-visible spawns without Forgejo.
workflow records synthetic `null-*` references. Use this for contract review - **`null`:** dry-run / audit.
and emergency rollback. - **`rest`:** live issue-core; requires healthy backend and intentional policy.
- `ISSUE_SINK_TYPE=rest`: live task creation. Sink failures raise out of Railiance production uses `state-hub` unless an overlay explicitly sets
`emit_tasks`, so Temporal retries and the workflow history make failures `rest` for an experiment.
visible. Railiance runtime ConfigMap uses this mode once
`ISSUE_CORE_API_KEY` is present in `actcore-runtime-secret`.
- `ISSUE_SINK_TYPE=state-hub`: ACTIVITY-WP-0021 path B. Each TaskSpec is posted
as a State Hub progress event (`activity_task_spawn` by default) instead of
creating a Forgejo issue. Use when issue-core→Forgejo is down or automated
Forgejo issues are policy-blocked.
### Known production failure (2026-07-21) ### Known production failure (2026-07-21) — rest path
`POST /issues/` returned **HTTP 503** with: `POST /issues/` returned **HTTP 503**:
```text ```text
Failed to connect to backend 'forgejo-inbox': Failed to connect to Gitea API Failed to connect to backend 'forgejo-inbox': Failed to connect to Gitea API
``` ```
Root cause on coulombcore issue-core: `GITEA_BACKEND_TOKEN` is rejected by `GITEA_BACKEND_TOKEN` on issue-core rejected by Forgejo. Fix is issue-core
Forgejo (`/api/v1/user` → 401 user does not exist). Healthz stays 200. token rotation (`warden route show issue-core-ingestion-api-key`), not
activity-core defaults.
**Operator fix (path A):** rotate `GITEA_BACKEND_TOKEN` in OpenBao path ## Promotion to rest (one definition at a time)
`platform/workloads/issue-core/issue-core/issue-core-runtime` (see
`warden route show issue-core-ingestion-api-key`) using a valid Forgejo PAT for
the issue-core service identity, then restart `issue-core` so the entrypoint
rewrites backends.json. Smoke from the worker:
```bash 1. Dry-run with `null` or observe `state-hub` spawns.
# From actcore-worker (does not print secrets) 2. Confirm issue-core smoke returns **201**.
python -c "import os,httpx; r=httpx.post(os.environ['ISSUE_CORE_URL']+'/issues/', 3. Temporarily set `ISSUE_SINK_TYPE=rest` only if policy allows external issues.
json={...full TaskSpec payload...}, 4. Prefer per-definition future opt-in over global rest (WP-0022).
headers={'Authorization':'Bearer '+os.environ['ISSUE_CORE_API_KEY']}, timeout=30); 5. Roll back: `ISSUE_SINK_TYPE=state-hub` or `null` + worker restart.
print(r.status_code, r.text[:200])"
```
Expect **201**, not 503.
Weekly SBOM staleness now posts a deterministic `sbom_staleness` progress report
even when task emission is disabled.
## Promotion and rollback
### Promote one definition safely
1. Keep `ISSUE_SINK_TYPE=null` and run or wait for the target definition.
2. Review rendered task specs in `task_spawn_log` (source id, condition,
target repo, synthetic `null-*` reference).
3. Confirm `ISSUE_CORE_URL` reachability and a populated `ISSUE_CORE_API_KEY`
on both activity-core and issue-core (same value). Credential custody:
`warden route show issue-core-ingestion-api-key --json`.
4. Run the repo smoke:
```bash
uv run python scripts/smoke_issue_core_emission.py
ISSUE_CORE_URL=http://127.0.0.1:8765 ISSUE_CORE_API_KEY=... \
uv run python scripts/smoke_issue_core_emission.py --live
```
5. Set `ISSUE_SINK_TYPE=rest` in `actcore-runtime-config`, apply
`k8s/railiance/15-externalsecret-issue-core.yaml` so External Secrets merges
`ISSUE_CORE_API_KEY` into `actcore-runtime-secret`, and restart
`actcore-worker` / `actcore-event-router` after the ExternalSecret is Ready.
6. Trigger one known-safe run (weekly SBOM staleness on a stale fixture or
manual `/activity-definitions/<id>/trigger`) and confirm `task_spawn_log`
stores the real `issue_id` returned by issue-core.
### Roll back to null-sink
1. Set `ISSUE_SINK_TYPE=null` in `actcore-runtime-config`.
2. `kubectl -n activity-core rollout restart deploy/actcore-worker deploy/actcore-event-router`
3. Verify the next run records synthetic `null-*` references again.
4. Leave issue-core tasks already created in place; activity-core does not own
downstream task lifecycle. Close or cancel duplicates in issue-core if a
promotion experiment created unexpected tasks.
Duplicate handling today: issue-core REST ingest does not yet dedupe on
`triggering_event_id`; Temporal retry visibility is the current guardrail.
Treat promotion as one-definition-at-a-time until server-side idempotency ships.
## Verification ## Verification
Local contract tests cover the rendered weekly SBOM task path and the REST
payload shape:
```bash ```bash
uv run pytest tests/test_integration_event_bridge.py tests/test_issue_sink.py uv run pytest tests/test_issue_sink.py tests/test_integration_event_bridge.py -q
``` ```
For a live environment, run with `ISSUE_SINK_TYPE=null` first and confirm ## Side-effect resolvers (not IssueSink)
`task_spawn_log` contains the expected source id, condition, triggering event id,
and synthetic task reference. Then switch to `ISSUE_SINK_TYPE=rest` only after a `forgejo_package_prune` with `apply: true` is a **declared platform side-effect**,
single known-safe rule match creates one issue-core task with the same fields. not task emission. It requires a non-empty `live_images_file` (ACTIVITY-WP-0023-T03).
See runbook § Weekly maintenance.

View file

@ -238,6 +238,43 @@ kubectl -n activity-core exec deploy/actcore-worker -- /app/.venv/bin/python3 -c
' '
``` ```
### Where progress evidence lives (edge vs workstation)
Prod activations post to **`http://actcore-statehub-edge-relay:8000`** on
railiance01 (upstream in-cluster `state-hub`). That feed is **not always** the
same history as workstation `http://127.0.0.1:8000` (local primary vs tunnel).
After a fire, query the edge from the worker:
```bash
ssh railiance01 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl -n activity-core exec deploy/actcore-worker -- /app/.venv/bin/python3 -c "
import urllib.request, json
for et in [\"daily_triage\",\"forgejo_package_prune\",\"activity_task_spawn\",\"sbom_staleness\"]:
d=json.loads(urllib.request.urlopen(
f\"http://actcore-statehub-edge-relay:8000/progress/?event_type={et}&limit=3\").read())
print(et, d[0][\"created_at\"] if d else None, (d[0].get(\"summary\") or \"\")[:80] if d else \"\")
"'
```
## IssueSink / task emission
Default: **`ISSUE_SINK_TYPE=state-hub`** (ACTIVITY-WP-0022). See
`docs/issue-core-emission-boundary.md` and
`docs/task-emission-consumer-contract.md`.
| Mode | Use |
| --- | --- |
| `state-hub` | Internal findings (default) |
| `null` | Dry-run |
| `rest` | Intentional issue-core / external tracker only |
`TaskExecutorWorkflow` is **disabled** unless
`ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB=true` (legacy tests only).
`review_required` on instructions is **metadata only** until a downstream
review queue exists (issue-core / work-record lane) — see ACTIVITY-WP-0023-T09.
Example distinction from the June 2026 daily triage evidence: Example distinction from the June 2026 daily triage evidence:
```text ```text
@ -332,14 +369,31 @@ mount of `~/railiance-platform` on the worker) with `apply: true` and posts
**Enabled 2026-07-21** after dry-run + first apply evidence **Enabled 2026-07-21** after dry-run + first apply evidence
(`railiance-platform/docs/evidence/forgejo-package-prune-apply-20260721.json`: (`railiance-platform/docs/evidence/forgejo-package-prune-apply-20260721.json`:
38 deleted, 0 errors). Manual apply: 38 deleted, 0 errors).
**Apply safety (ACTIVITY-WP-0023-T03):** `apply: true` **refuses** to run without
a non-empty `live_images_file` (or `FORGEJO_LIVE_IMAGES_FILE`). Allowed
side-effect shell query with apply today: **`forgejo_package_prune` only**.
Refresh protection list after cluster image rollouts:
```bash
# From workstation with both contexts, or merge scp'd exports on railiance01:
./scripts/refresh_live_images.sh
# railiance01 worker hostPath target:
OUT=~/railiance-platform/docs/evidence/live-images-all.txt \
EXTRA_LIVE_FILES=/path/to/coulombcore-export.txt \
./scripts/refresh_live_images.sh
```
Manual apply:
```bash ```bash
cd ~/railiance-platform cd ~/railiance-platform
export VAULT_ADDR=https://bao.coulomb.social export VAULT_ADDR=https://bao.coulomb.social
# OIDC or platform token — never paste PAT into chat # OIDC or platform token — never paste PAT into chat
export FORGEJO_TOKEN=$(bao kv get -field=API_TOKEN platform/workloads/forgejo/forgejo-admin) export FORGEJO_TOKEN=$(bao kv get -field=API_TOKEN platform/workloads/forgejo/forgejo-admin)
./tools/cmd/forgejo-package-prune --apply --live-images-file /path/to/live-images.txt ./tools/cmd/forgejo-package-prune --apply --live-images-file docs/evidence/live-images-all.txt
``` ```
`weekly-sbom-staleness` is the canonical rule-only weekly maintenance schedule. `weekly-sbom-staleness` is the canonical rule-only weekly maintenance schedule.

View file

@ -0,0 +1,78 @@
# Task emission consumer contract
**Audience:** agent-harness, per-repo Temporal workers, operators.
**Owners:** activity-core (producer), consumers (executors).
**Related:** ACTIVITY-WP-0022 (sink policy), ACTIVITY-WP-0023-T02 (executor gap).
activity-core answers **when / what / where**. It does **not** execute work.
Consumers must pick up emitted tasks and produce domain evidence.
## Sink matrix (ACTIVITY-WP-0022)
| `ISSUE_SINK_TYPE` | Behaviour | When to use |
| --- | --- | --- |
| **`state-hub` (default)** | POST State Hub `/progress/` with `event_type=activity_task_spawn` (override via `STATE_HUB_TASK_EVENT_TYPE`) | Internal fleet findings; no Forgejo issues |
| **`null`** | Synthetic `null-*` refs in `task_spawn_log` only | Dry-run / contract review |
| **`rest`** | POST issue-core `/issues/` (may project to Forgejo) | **Explicit opt-in** only when external tracker issues are intended and backend is healthy |
Unset or unknown values fall back to **`state-hub`** (safe default).
## Payload: `activity_task_spawn` (State Hub)
Produced by `StateHubProgressSink`. Consumers should treat `detail` as the
authoritative task spec.
```json
{
"event_type": "activity_task_spawn",
"author": "activity-core",
"summary": "<task title, max ~240 chars>",
"detail": {
"task_ref": "sh-<uuid>",
"title": "Run Binky daily rhythm (daily_brief) for 2026-07-21",
"description": "...",
"target_repo": "binky-control",
"priority": "medium",
"labels": ["binky", "rhythm", "automated"],
"source_type": "rule",
"source_id": "emit-daily-rhythm-task",
"triggering_event_id": "manual-… or scheduled",
"activity_definition_id": "<activity uuid>",
"backend": "state-hub-progress"
}
}
```
### Required consumer behaviour
1. **Idempotency:** key on `detail.task_ref` or
`(activity_definition_id, triggering_event_id, source_id, title)`.
2. **Routing:** use `target_repo` (and labels) to select checkout / lane.
3. **Completion evidence:** post a domain progress event when work finishes
(e.g. Binky: `event_type=binky_daily_brief` with `detail.repo=binky-control`
and date), so rhythm resolvers can set `due=false`.
4. **Do not** re-implement task lifecycle in activity-core.
## Payload: issue-core REST (`ISSUE_SINK_TYPE=rest`)
See `docs/issue-core-emission-boundary.md`. `task_spawn_log.task_ref` is the
issue-core `issue_id`. Prefer this only for intentional external projection.
## Binky daily brief path (reference)
1. Schedule / one-shot: `Binky Daily Operating Rhythm`.
2. Context: `binky_rhythm_status``due=true` for `daily_brief`.
3. Rule emit → sink (`state-hub` recommended).
4. **Consumer (out of repo):** agent-harness or operator session runs
`binky-control` OperatingRhythm / brief scripts.
5. Completion: post `binky_daily_brief` progress so the next fire is not due.
Until a harness consumer is wired, operators may complete the brief manually
and still post the progress event — spawn without completion leaves `due=true`.
## Anti-patterns
- Using `TaskExecutorWorkflow` in activity-core for real work (disabled by
default; ACTIVITY-WP-0023-T08).
- Global `ISSUE_SINK_TYPE=rest` for all definitions (reintroduces Forgejo spam).
- Treating `task_spawn_log` as task status authority.

View file

@ -26,7 +26,9 @@ the ConfigMap projection from that file before enabling the probe schedule.
`OPS_HUB_KEY` is created only as an empty Secret placeholder until the operator `OPS_HUB_KEY` is created only as an empty Secret placeholder until the operator
provisions the Inter-Hub ops-hub key. provisions the Inter-Hub ops-hub key.
`ISSUE_SINK_TYPE` is `rest` in `actcore-runtime-config`. `ISSUE_CORE_API_KEY` `ISSUE_SINK_TYPE` defaults to **`state-hub`** (ACTIVITY-WP-0022; no silent Forgejo
issues). Set `rest` only for intentional issue-core projection when the backend
is healthy. `ISSUE_CORE_API_KEY`
is synced from OpenBao into `actcore-runtime-secret` by ExternalSecret is synced from OpenBao into `actcore-runtime-secret` by ExternalSecret
`actcore-issue-core-runtime` (same path as issue-core: `actcore-issue-core-runtime` (same path as issue-core:
`platform/workloads/issue-core/issue-core/issue-core-runtime`). Prereqs: `platform/workloads/issue-core/issue-core/issue-core-runtime`). Prereqs:

86
scripts/refresh_live_images.sh Executable file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# ACTIVITY-WP-0023-T04: refresh multi-cluster live Forgejo image protection list.
#
# Exports container images matching forgejo.coulomb.social from one or more
# kubectl contexts and merges them into a single non-secret list used by
# weekly-forgejo-package-prune (live_images_file).
#
# Usage:
# ./scripts/refresh_live_images.sh
# OUT=/path/to/live-images-all.txt CONTEXTS="default hosteurope" ./scripts/refresh_live_images.sh
#
# On railiance01 (worker hostPath target):
# OUT=~/railiance-platform/docs/evidence/live-images-all.txt ./scripts/refresh_live_images.sh
set -euo pipefail
OUT="${OUT:-${HOME}/railiance-platform/docs/evidence/live-images-all.txt}"
# Space-separated kubeconfig contexts (empty = current default context only)
CONTEXTS="${CONTEXTS:-}"
PATTERN="${FORGEJO_IMAGE_PATTERN:-forgejo.coulomb.social}"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
export_one() {
local ctx="$1"
local dest="$2"
local args=()
if [[ -n "$ctx" ]]; then
args=(--context "$ctx")
fi
if ! kubectl "${args[@]}" get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' \
2>/dev/null | grep -E "$PATTERN" | sort -u >"$dest"; then
# also try hosteurope-style if default failed
kubectl "${args[@]}" get pods -A -o json 2>/dev/null \
| python3 -c "
import json,sys,re
pat=re.compile(r'${PATTERN}')
try:
d=json.load(sys.stdin)
except Exception:
sys.exit(0)
for it in d.get('items',[]):
for c in (it.get('spec') or {}).get('containers') or []:
img=c.get('image') or ''
if pat.search(img):
print(img)
" | sort -u >"$dest" || true
fi
# hostPath / crictl fallback not required; empty file is ok for this context
local n
n=$(wc -l <"$dest" | tr -d ' ')
echo "context=${ctx:-current}: ${n} forgejo images" >&2
}
if [[ -z "$CONTEXTS" ]]; then
export_one "" "$tmpdir/a.txt"
cat "$tmpdir/a.txt" >"$tmpdir/all.txt"
else
: >"$tmpdir/all.txt"
i=0
for ctx in $CONTEXTS; do
export_one "$ctx" "$tmpdir/c$i.txt"
cat "$tmpdir/c$i.txt" >>"$tmpdir/all.txt"
i=$((i + 1))
done
fi
# Also accept extra files to merge (e.g. coulombcore export scp'd earlier)
if [[ -n "${EXTRA_LIVE_FILES:-}" ]]; then
for f in $EXTRA_LIVE_FILES; do
if [[ -f "$f" ]]; then
cat "$f" >>"$tmpdir/all.txt"
echo "merged extra $f" >&2
fi
done
fi
sort -u "$tmpdir/all.txt" | grep -v '^$' >"$tmpdir/merged.txt" || true
mkdir -p "$(dirname "$OUT")"
cp "$tmpdir/merged.txt" "$OUT"
count=$(wc -l <"$OUT" | tr -d ' ')
echo "wrote $OUT ($count unique images)" >&2
if [[ "$count" -eq 0 ]]; then
echo "WARNING: empty live-images list — prune apply will refuse (ACTIVITY-WP-0023-T03)" >&2
exit 2
fi

View file

@ -29,17 +29,30 @@ def forgejo_package_prune(params: dict[str, Any]) -> dict[str, Any]:
if apply: if apply:
cmd.append("--apply") cmd.append("--apply")
# ACTIVITY-WP-0020: worker pods often lack kubectl, so live-tag protection # ACTIVITY-WP-0020 / 0023-T03: worker pods often lack kubectl, so live-tag
# must come from an explicit multi-cluster image list file (hostPath). # protection must come from an explicit multi-cluster image list file.
# Apply is refused without a non-empty protection file (prevents the
# 2026-07-21 incident that deleted live state-hub tags).
live_images = params.get("live_images_file") or os.environ.get( live_images = params.get("live_images_file") or os.environ.get(
"FORGEJO_LIVE_IMAGES_FILE", "" "FORGEJO_LIVE_IMAGES_FILE", ""
) )
live_path: Path | None = None
if live_images: if live_images:
live_path = Path(str(live_images)).expanduser() live_path = Path(str(live_images)).expanduser()
if live_path.is_file(): if live_path.is_file() and live_path.stat().st_size > 0:
cmd.append(f"--live-images-file={live_path}") cmd.append(f"--live-images-file={live_path}")
elif apply:
raise RuntimeError(
f"forgejo_package_prune apply=true requires a non-empty "
f"live_images_file; missing or empty: {live_path}"
)
else: else:
logger.warning("live_images_file not found: %s", live_path) logger.warning("live_images_file not found or empty: %s", live_path)
elif apply:
raise RuntimeError(
"forgejo_package_prune apply=true requires params.live_images_file "
"or FORGEJO_LIVE_IMAGES_FILE (non-empty multi-cluster image list)"
)
env = os.environ.copy() env = os.environ.copy()
completed = subprocess.run( completed = subprocess.run(

View file

@ -2,9 +2,17 @@
IssueSink adapter interface and implementations. IssueSink adapter interface and implementations.
IssueSink is the outbound boundary between activity-core and task backends IssueSink is the outbound boundary between activity-core and task backends
(issue-core, etc.). It receives TaskSpec objects and returns TaskRef objects. (issue-core, State Hub progress, etc.). It receives TaskSpec objects and
returns TaskRef objects.
Active sink is selected by ISSUE_SINK_TYPE env var: "rest" (default) | "null". Active sink is selected by ISSUE_SINK_TYPE:
state-hub (default) State Hub progress (`activity_task_spawn`); no Forgejo
null dry-run synthetic refs
rest issue-core REST (explicit opt-in; may project to Forgejo)
ACTIVITY-WP-0022: default must not silently create Forgejo issues for internal
findings. Use rest only when intentionally projecting to an external tracker.
""" """
from __future__ import annotations from __future__ import annotations
@ -22,7 +30,9 @@ logger = logging.getLogger(__name__)
ISSUE_CORE_URL = os.environ.get("ISSUE_CORE_URL", "http://127.0.0.1:8765") ISSUE_CORE_URL = os.environ.get("ISSUE_CORE_URL", "http://127.0.0.1:8765")
ISSUE_CORE_API_KEY_ENV = "ISSUE_CORE_API_KEY" ISSUE_CORE_API_KEY_ENV = "ISSUE_CORE_API_KEY"
ISSUE_SINK_TYPE = os.environ.get("ISSUE_SINK_TYPE", "rest") # Safe default for internal fleet findings (ACTIVITY-WP-0022).
DEFAULT_ISSUE_SINK_TYPE = "state-hub"
ISSUE_SINK_TYPE = os.environ.get("ISSUE_SINK_TYPE", DEFAULT_ISSUE_SINK_TYPE)
class IssueSink(ABC): class IssueSink(ABC):
@ -188,9 +198,18 @@ def get_issue_sink() -> IssueSink:
Re-reads the env on each call so ConfigMap/env patches apply without Re-reads the env on each call so ConfigMap/env patches apply without
requiring a module reload (ACTIVITY-WP-0021). requiring a module reload (ACTIVITY-WP-0021).
""" """
sink_type = os.environ.get("ISSUE_SINK_TYPE", ISSUE_SINK_TYPE).lower() sink_type = os.environ.get("ISSUE_SINK_TYPE", DEFAULT_ISSUE_SINK_TYPE).strip().lower()
if not sink_type:
sink_type = DEFAULT_ISSUE_SINK_TYPE
if sink_type == "null": if sink_type == "null":
return NullSink() return NullSink()
if sink_type in {"state-hub", "state_hub", "progress"}: if sink_type in {"state-hub", "state_hub", "progress"}:
return StateHubProgressSink() return StateHubProgressSink()
return IssueCoreRestSink() if sink_type == "rest":
return IssueCoreRestSink()
logger.warning(
"unknown ISSUE_SINK_TYPE=%r — falling back to %s (safe default)",
sink_type,
DEFAULT_ISSUE_SINK_TYPE,
)
return StateHubProgressSink()

View file

@ -113,23 +113,51 @@ async def run() -> None:
], ],
) )
task_worker = Worker( # ACTIVITY-WP-0023-T08: only register the legacy task-execution stub when
client, # explicitly enabled. Default is orchestrator-only so production does not
task_queue=TASK_EXECUTION_TASK_QUEUE, # advertise a fake execution surface on task-execution-tq.
workflows=[TaskExecutorWorkflow], enable_task_stub = os.environ.get(
activities=[persist_task_instance], "ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB", ""
) ).strip().lower() in {"1", "true", "yes", "on"}
task_worker: Worker | None = None
if enable_task_stub:
task_worker = Worker(
client,
task_queue=TASK_EXECUTION_TASK_QUEUE,
workflows=[TaskExecutorWorkflow],
activities=[persist_task_instance],
)
logger.warning(
"TaskExecutorWorkflow stub ENABLED on %s — not for production execution",
TASK_EXECUTION_TASK_QUEUE,
)
else:
logger.info(
"TaskExecutorWorkflow stub not registered "
"(set ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB=true for legacy tests only)"
)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
stop = asyncio.Event() stop = asyncio.Event()
loop.add_signal_handler(signal.SIGTERM, stop.set) loop.add_signal_handler(signal.SIGTERM, stop.set)
loop.add_signal_handler(signal.SIGINT, stop.set) loop.add_signal_handler(signal.SIGINT, stop.set)
async with orchestrator_worker, task_worker: workers = [orchestrator_worker]
if task_worker is not None:
workers.append(task_worker)
from contextlib import AsyncExitStack
async with AsyncExitStack() as stack:
for w in workers:
await stack.enter_async_context(w)
queues = [ORCHESTRATOR_TASK_QUEUE]
if enable_task_stub:
queues.append(TASK_EXECUTION_TASK_QUEUE)
logger.info( logger.info(
"Workers running — queues: %r, %r (namespace=%r)", "Workers running — queues: %r (namespace=%r)",
ORCHESTRATOR_TASK_QUEUE, queues,
TASK_EXECUTION_TASK_QUEUE,
TEMPORAL_NAMESPACE, TEMPORAL_NAMESPACE,
) )
await stop.wait() await stop.wait()

View file

@ -214,26 +214,45 @@ class RunActivityWorkflow:
@workflow.defn @workflow.defn
class TaskExecutorWorkflow: class TaskExecutorWorkflow:
"""Compatibility stub for legacy task-instance workflows. """LEGACY NO-OP — not a production execution surface (ACTIVITY-WP-0023-T08).
This is not a production execution surface for activity-core. It persists a Historical compatibility stub. Real task execution belongs in per-repo
task_instances row with status=done and returns immediately so legacy/dev workers / agent-harness, not activity-core.
flows keep their idempotency behavior. Real task execution belongs in
per-repo workers or a future execution-owned repo/workplan, not here.
task_id is derived deterministically from the workflow's own ID so Behaviour is controlled by ``ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB``:
persist_task_instance retries remain idempotent.
- unset / false (default): refuse to run (logs error, raises) so the stub
cannot attract production work by accident.
- true: legacy behaviour persist a done ``task_instances`` row for
idempotent dev/test callers only.
""" """
@workflow.run @workflow.run
async def run(self, run_id: str, task_type: str, params: dict) -> dict: async def run(self, run_id: str, task_type: str, params: dict) -> dict:
# Keep the stub idempotent without implying task lifecycle ownership. enabled = (
os.environ.get("ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB", "")
.strip()
.lower()
in {"1", "true", "yes", "on"}
)
task_id = str( task_id = str(
uuid.uuid5(uuid.NAMESPACE_URL, workflow.info().workflow_id) uuid.uuid5(uuid.NAMESPACE_URL, workflow.info().workflow_id)
) )
workflow.logger.info( if not enabled:
"TaskExecutorWorkflow started", workflow.logger.error(
"TaskExecutorWorkflow refused: stub disabled "
"(set ACTIVITY_CORE_ENABLE_TASK_EXECUTOR_STUB=true only for legacy tests). "
"Real execution belongs in per-repo workers / agent-harness. "
"See docs/task-emission-consumer-contract.md"
)
raise RuntimeError(
"TaskExecutorWorkflow is disabled (ACTIVITY-WP-0023-T08). "
"Use per-repo executors; do not route production work here."
)
workflow.logger.warning(
"TaskExecutorWorkflow stub running (legacy mode)",
extra={"run_id": run_id, "task_type": task_type, "task_id": task_id}, extra={"run_id": run_id, "task_type": task_type, "task_id": task_id},
) )
@ -251,4 +270,4 @@ class TaskExecutorWorkflow:
retry_policy=_RETRY_POLICY, retry_policy=_RETRY_POLICY,
) )
return {"task_id": task_id, "status": "done"} return {"task_id": task_id, "status": "done", "legacy_stub": True}

View file

@ -36,4 +36,43 @@ def test_shell_resolver_runs_forgejo_package_prune(tmp_path, monkeypatch) -> Non
) )
assert result["kind"] == "forgejo_package_prune" assert result["kind"] == "forgejo_package_prune"
assert result["candidate_count"] == 2 assert result["candidate_count"] == 2
def test_apply_without_live_images_file_is_rejected(tmp_path) -> None:
"""ACTIVITY-WP-0023-T03: apply=true must not run unprotected."""
script = tmp_path / "forgejo-package-prune"
script.write_text("#!/usr/bin/env bash\necho '{}'\n", encoding="utf-8")
script.chmod(0o755)
try:
CONTEXT_RESOLVER_REGISTRY["shell"]().resolve(
"forgejo_package_prune",
None,
{"prune_script": str(script), "max_versions": 3, "apply": True},
)
raise AssertionError("expected RuntimeError")
except RuntimeError as exc:
assert "live_images_file" in str(exc)
def test_apply_with_empty_live_images_file_is_rejected(tmp_path) -> None:
script = tmp_path / "forgejo-package-prune"
script.write_text("#!/usr/bin/env bash\necho '{}'\n", encoding="utf-8")
script.chmod(0o755)
empty = tmp_path / "live.txt"
empty.write_text("", encoding="utf-8")
try:
CONTEXT_RESOLVER_REGISTRY["shell"]().resolve(
"forgejo_package_prune",
None,
{
"prune_script": str(script),
"apply": True,
"live_images_file": str(empty),
},
)
raise AssertionError("expected RuntimeError")
except RuntimeError as exc:
assert "non-empty" in str(exc) or "empty" in str(exc)

View file

@ -81,6 +81,31 @@ def test_issue_core_rest_sink_posts_task_contract(monkeypatch) -> None:
assert "review_required" not in posts[0]["json"] assert "review_required" not in posts[0]["json"]
def test_default_sink_is_state_hub_not_rest(monkeypatch) -> None:
"""ACTIVITY-WP-0022: unset ISSUE_SINK_TYPE must not open Forgejo/rest."""
from activity_core import issue_sink as mod
monkeypatch.delenv("ISSUE_SINK_TYPE", raising=False)
sink = mod.get_issue_sink()
assert type(sink).__name__ == "StateHubProgressSink"
def test_unknown_sink_type_falls_back_to_state_hub(monkeypatch) -> None:
from activity_core import issue_sink as mod
monkeypatch.setenv("ISSUE_SINK_TYPE", "forgejo-please")
sink = mod.get_issue_sink()
assert type(sink).__name__ == "StateHubProgressSink"
def test_rest_sink_still_available_when_explicit(monkeypatch) -> None:
from activity_core import issue_sink as mod
monkeypatch.setenv("ISSUE_SINK_TYPE", "rest")
sink = mod.get_issue_sink()
assert type(sink).__name__ == "IssueCoreRestSink"
def test_issue_core_rest_sink_requires_api_key() -> None: def test_issue_core_rest_sink_requires_api_key() -> None:
sink = IssueCoreRestSink("http://issue-core.test/", api_key="") sink = IssueCoreRestSink("http://issue-core.test/", api_key="")
with pytest.raises(RuntimeError, match="ISSUE_CORE_API_KEY"): with pytest.raises(RuntimeError, match="ISSUE_CORE_API_KEY"):

View file

@ -4,7 +4,7 @@ type: workplan
title: "IssueSink: stop default silent Forgejo issues for internal findings" title: "IssueSink: stop default silent Forgejo issues for internal findings"
domain: infotech domain: infotech
repo: activity-core repo: activity-core
status: proposed status: finished
owner: codex owner: codex
topic_slug: activity-core topic_slug: activity-core
created: "2026-07-21" created: "2026-07-21"
@ -36,7 +36,7 @@ create Forgejo issues for internal findings.
- the-custodian: CUST-WP-0060 closure review item 6 (TODO-stale / IssueSink) - the-custodian: CUST-WP-0060 closure review item 6 (TODO-stale / IssueSink)
- activity-core: `ACTIVITY-WP-0021` (prod reliability; T01 may restore *or* - activity-core: `ACTIVITY-WP-0021` (prod reliability; T01 may restore *or*
replace emit path — this WP owns the **policy** default) replace emit path — this WP owns the **policy** default)
- `src/activity_core/issue_sink.py``ISSUE_SINK_TYPE` defaults to `"rest"` - `src/activity_core/issue_sink.py``ISSUE_SINK_TYPE` defaults to `"state-hub"` (ACTIVITY-WP-0022)
## Goal ## Goal
@ -63,7 +63,7 @@ create Forgejo issues for internal findings.
```task ```task
id: ACTIVITY-WP-0022-T01 id: ACTIVITY-WP-0022-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "1a0d9bf6-c822-42c8-9b67-8a9be16cce7d" state_hub_task_id: "1a0d9bf6-c822-42c8-9b67-8a9be16cce7d"
``` ```
@ -82,7 +82,7 @@ and ISSUE-WP-0004 origin.
```task ```task
id: ACTIVITY-WP-0022-T02 id: ACTIVITY-WP-0022-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "78f994f6-68bd-4f18-88b0-3a25b46e7563" state_hub_task_id: "78f994f6-68bd-4f18-88b0-3a25b46e7563"
``` ```
@ -95,7 +95,7 @@ Forgejo. Update tests (`tests/test_issue_sink.py` and callers of
```task ```task
id: ACTIVITY-WP-0022-T03 id: ACTIVITY-WP-0022-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "d45f861d-4c5e-4721-8e02-50d7deafaefd" state_hub_task_id: "d45f861d-4c5e-4721-8e02-50d7deafaefd"
``` ```
@ -109,7 +109,7 @@ paused.
```task ```task
id: ACTIVITY-WP-0022-T04 id: ACTIVITY-WP-0022-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "747a19ab-c6a5-4f8a-b294-a8dc0f82109f" state_hub_task_id: "747a19ab-c6a5-4f8a-b294-a8dc0f82109f"
``` ```
@ -133,3 +133,23 @@ match policy after T02.
- issue-core `INTENT.md` (pivot 2026-07-20) - issue-core `INTENT.md` (pivot 2026-07-20)
- `the-custodian/canon/standards/work-record-types_v0.1.md` - `the-custodian/canon/standards/work-record-types_v0.1.md`
- `the-custodian/research/WorkOrchestrationArchitectureDraft.md` §4.2 - `the-custodian/research/WorkOrchestrationArchitectureDraft.md` §4.2
## Closeout 2026-07-21
**Policy decision (T01):** `ISSUE_SINK_TYPE` default = **`state-hub`**. `rest` is
explicit opt-in only. Unknown/empty → state-hub.
**Code (T02):** `DEFAULT_ISSUE_SINK_TYPE`, factory fallback, tests.
**Definitions (T03):** `daily-todo-md-stale-review` already paused/disabled in
prod; SBOM task rules gated off; internal paths use reports / state-hub.
**Docs (T04):** `docs/issue-core-emission-boundary.md` sink matrix,
`docs/task-emission-consumer-contract.md`, `.env.example`, runbook.
Acceptance checkboxes:
- [x] Default does not open Forgejo issues for internal findings
- [x] Intentional `rest` still available
- [x] TODO-stale not depending on IssueSink→Forgejo
- [x] Tests and deploy docs match

View file

@ -4,7 +4,7 @@ type: workplan
title: "Intentscope gap closure and WP-0020/0021 operational follow-ups" title: "Intentscope gap closure and WP-0020/0021 operational follow-ups"
domain: infotech domain: infotech
repo: activity-core repo: activity-core
status: proposed status: active
owner: codex owner: codex
topic_slug: activity-core topic_slug: activity-core
created: "2026-07-21" created: "2026-07-21"
@ -62,7 +62,7 @@ note — so SCOPE stays truthful and INTENTs three questions remain load-bear
```task ```task
id: ACTIVITY-WP-0023-T01 id: ACTIVITY-WP-0023-T01
status: wait status: done
priority: high priority: high
state_hub_task_id: "144197ee-5ed1-40be-b4fe-ba5683fc0579" state_hub_task_id: "144197ee-5ed1-40be-b4fe-ba5683fc0579"
``` ```
@ -84,7 +84,7 @@ tasks for docs/defaults are cross-linked as done.
```task ```task
id: ACTIVITY-WP-0023-T02 id: ACTIVITY-WP-0023-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "fbfd3796-736a-4b75-a391-20ce29eeca48" state_hub_task_id: "fbfd3796-736a-4b75-a391-20ce29eeca48"
``` ```
@ -106,7 +106,7 @@ INTENT: execution lives in per-repo workers / harness — not activity-core.
```task ```task
id: ACTIVITY-WP-0023-T03 id: ACTIVITY-WP-0023-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "7626820e-f823-4d20-936d-1aecc2828caf" state_hub_task_id: "7626820e-f823-4d20-936d-1aecc2828caf"
``` ```
@ -124,7 +124,7 @@ source; docs list allowed side-effect definitions.
```task ```task
id: ACTIVITY-WP-0023-T04 id: ACTIVITY-WP-0023-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "50945834-b005-4010-9dc7-6abf071b6aa8" state_hub_task_id: "50945834-b005-4010-9dc7-6abf071b6aa8"
``` ```
@ -201,7 +201,7 @@ or MarkiTect-missing errors for configured domains.
```task ```task
id: ACTIVITY-WP-0023-T08 id: ACTIVITY-WP-0023-T08
status: todo status: done
priority: low priority: low
state_hub_task_id: "17b863e9-955a-402e-ba20-a5189556e21d" state_hub_task_id: "17b863e9-955a-402e-ba20-a5189556e21d"
``` ```
@ -217,7 +217,7 @@ state_hub_task_id: "17b863e9-955a-402e-ba20-a5189556e21d"
```task ```task
id: ACTIVITY-WP-0023-T09 id: ACTIVITY-WP-0023-T09
status: todo status: done
priority: low priority: low
state_hub_task_id: "24bbf6eb-bf83-4d99-9a2a-0e207ead59c0" state_hub_task_id: "24bbf6eb-bf83-4d99-9a2a-0e207ead59c0"
``` ```
@ -233,7 +233,7 @@ state_hub_task_id: "24bbf6eb-bf83-4d99-9a2a-0e207ead59c0"
```task ```task
id: ACTIVITY-WP-0023-T10 id: ACTIVITY-WP-0023-T10
status: todo status: done
priority: low priority: low
state_hub_task_id: "05b3e280-da88-45da-833e-3bd4ee5eaea3" state_hub_task_id: "05b3e280-da88-45da-833e-3bd4ee5eaea3"
``` ```
@ -263,3 +263,18 @@ guessing hub topology.
- `ACTIVITY-WP-0021` closeout (state-hub sink, schedule harden, ROS template) - `ACTIVITY-WP-0021` closeout (state-hub sink, schedule harden, ROS template)
- `ACTIVITY-WP-0022` IssueSink no-default-Forgejo - `ACTIVITY-WP-0022` IssueSink no-default-Forgejo
- `docs/issue-core-emission-boundary.md` - `docs/issue-core-emission-boundary.md`
## Progress 2026-07-21 (implementation session)
| Task | Status | Notes |
| --- | --- | --- |
| T01 | done | INTENT + emission docs + default state-hub after WP-0022 |
| T02 | done | Consumer contract doc; Binky completion path documented (harness external) |
| T03 | done | apply=true hard-fails without non-empty live_images_file |
| T04 | done | `scripts/refresh_live_images.sh` + make target |
| T05 | todo | OpenBao ESO policy still 403 on forgejo-admin |
| T06 | todo | issue-core GITEA_BACKEND_TOKEN rotation (external) |
| T07 | todo | MarkiTect in state-hub image (external) |
| T08 | done | TaskExecutor stub disabled by default |
| T09 | done | review_required documented as metadata-only |
| T10 | done | Edge vs workstation evidence in runbook |