# RStack Harness

<!-- owner: RStack developed by Richardson Gunde -->

The RStack Harness is the reliability layer around the agents, skills, prompts, and plugins in this package. It does not replace agents. It gives them deterministic run state, contract checks, evidence, and guardrails so a task cannot be treated as complete based on prose alone.

## Canonical SDLC stages

The canonical 15-stage SDLC pipeline lives in `src/core/harness/stages.js`:

```text
00-environment
01-transcript
02-requirements
03-documentation
04-planning
05-jira
06-architecture
07-code
08-testing
09-deployment
10-summary
11-feedback-loop
12-security-threat-model
13-compliance-checker
14-cost-estimation
```

Tests fail if the list is not exactly 15 stages or if the order changes.

## Run folder shape

New runs prepare clean stage folders under:

```text
.rstack/runs/<run_id>/
  artifacts/
    stages/
      00-environment/
      01-transcript/
      ...
      14-cost-estimation/
  tasks/<task_id>/
    prompt.md
    builder.json
    validation.json
  events.jsonl
```

Root artifacts such as `artifacts/requirements.json` remain compatibility outputs. Canonical stage output should go under `artifacts/stages/<stage-id>/` when a stage target is listed in the task prompt.

## Contract checks

Builder contracts are validated by `src/core/harness/contracts.js` and require:

```text
task_id, agent, status, summary, files_modified, tests_run, risks, next_steps
```

Validator contracts require:

```text
task_id, validator, status, checks, issues, retry_recommendation
```

The Pi extension uses these shared checks in `sdlc_validate`. For PASS and DONE_WITH_CONCERNS builders, `sdlc_validate` also requires meaningful `summary`, non-empty `tests_run`, `memory_summary.work_done`, `memory_summary.evidence`, and one evidence-backed `stage_summaries` entry for each canonical stage target listed in the task prompt.

### Validator registry

`src/core/harness/validator-registry.js` maps the critical SDLC stages (`06-architecture`, `07-code`, `08-testing`, `12-security-threat-model`, `13-compliance-checker`) to stage-specific validator profiles: `validator` id, advisory `model_hint`, `read_only: true`, `required_checks`, and `output_contract_fields`. Stages without a registered entry get the generic profile (`validator.generic`). When a task targets several canonical stages, `resolveValidatorProfile` picks the highest-priority registered one (security > compliance > code > testing > architecture).

Projects can override entries per stage in `.rstack/validators/registry.json`:

```json
{
  "07-code": { "model_hint": "sonnet" },
  "09-deployment": { "validator": "validator.09-deployment", "required_checks": ["deployment_report_exists"] }
}
```

Partial entries deep-merge over the defaults per stage; overrides for canonical stages not in the default registry are layered over the generic profile. A malformed file warns loudly and the defaults apply, and `read_only` can never be flipped to `false`.

`sdlc_validate` resolves the profile from the task's canonical stage targets and records it in `validation.json` as `validator_profile` (`stage_id`, `validator`, `model_hint`, `required_checks`) alongside the existing `validator` field, plus an informational `validator_profile_selected` check. Executing `required_checks` per profile is future work — the recorded profile is the routing contract.

## Environment intake (#237)

Stage 00 runs an interactive intake instead of guessing project context. Detection is `rstack-agents env scan [--json]` — a read-only wrap of the adopt scanner that adds:

- `proposed_run_mode`: `greenfield` | `brownfield` | `feature`, with `run_mode_evidence[]` naming the exact markers (adoption markers in the latest run → brownfield; an adoption run alongside later runs → feature; git history + manifests with no `.rstack` runs → brownfield; else greenfield).
- `setup_needs[]`: `{ kind, platform, required_vars[], satisfied }` derived from `.rstack/integrations.json` platform choices vs env-var presence (names only — values are never read into any report).

**environment_report.json v2 (additive)** — the report may carry `run_mode`, `run_mode_evidence[]`, `user_preferences` (string map, e.g. `ticketing_platform`), and `setup_needs[]`. `src/core/harness/environment-report.js` validates the shape: legacy fields warn-only (pre-#237 reports never start failing), intake fields strictly typed when present, credential-shaped `user_preferences` keys rejected. `sdlc_validate` runs it best-effort for stage-00 tasks as the `environment_report_shape` check — PASS or WARN by construction, never a verdict flip, and a throw can never fail validation (context-pressure precedent).

**`.rstack/integrations.json`** — endpoints and identifiers ONLY (registered in the config-validation registry): `ticketing {provider: jira|github|azure_devops|linear|file-based, base_url?, project_key?}`, `docs {provider?: confluence|none, space_key?}`, `notifications {channel?: slack|teams|discord|none}`. Any key shaped like a credential (token/secret/password/api key) is a validation error — secrets live in `.env`. `init` writes a self-documenting template when the file is missing (`_comment` keys are ignored by the validator).

**Decision-driven setup** — stage 00 confirms the run mode via ONE Decision Queue item (required before `01-transcript`), and each unsatisfied setup_need becomes a decision gated on the stage that consumes it (ticketing → `05-jira`, deployment → `09-deployment`, notifications → `10-summary`), so the DOR gate blocks exactly the work that needs the answer and nothing earlier. NEEDS_CONTEXT stays reserved for true blockers.

## Evidence ledger

Raw runtime events are appended to `events.jsonl`. Validator-grounded task evidence is appended to `evidence.jsonl` with:

```json
{"task_id":"004-implementation","kind":"validation","status":"PASS","evidence":"tasks/004-implementation/validation.json"}
```

`src/core/harness/evidence.js` rejects missing `task_id`, `kind`, `status`, or `evidence` fields.

## Attempt ledger (#481)

A stage attempt used to span independent best-effort file writes (the
`tasks.json` `IN_PROGRESS` stamp, prompt, `builder.json`, `validation.json`)
with no single authoritative commit point and no retained history across
retries. `src/core/harness/attempt-ledger.js` adds:

- an **immutable per-attempt directory** under
  `.rstack/runs/<run_id>/tasks/<stage>/attempts/<attempt-id>/` —
  `claim.json`/`builder.json`/`validation.json`/`commit.json`, never
  overwritten by a later attempt (every retry gets a new zero-padded
  `attempt-id`, `001`, `002`, ...);
- a **CAS-guarded run-level ledger** (`attempt-ledger.json`) recording the
  current `{state, attempt_id, version, claim_nonce, lease}` per task,
  independent of whether any particular contract file happens to exist on
  disk;
- a **transactional outbox** appended to each attempt's `commit.json` so a
  committed transition's side effects can be delivered exactly once even
  across a process kill mid-fan-out (`drainOutbox` tracks delivered ids and
  skips anything already applied on retry).

State machine: `READY → CLAIMED → BUILT → [EXECUTING] → VALIDATING → {PASS,
CHANGES_REQUESTED, BLOCKED_INFRA, BLOCKED_POLICY, NEEDS_CONTEXT}` — `EXECUTING`
is optional (`BUILT` may go straight to `VALIDATING`) and can itself resolve
to `BLOCKED_INFRA`. (The last of those five terminal states is this
harness's own `NEEDS_CONTEXT` retry outcome, added alongside the four the
design lists). `CLAIMED` can also resolve straight to a terminal state
without ever reaching `BUILT` — a builder that never wrote a contract at all
still gets a verdict.

**Storage decision** (documented per the design's own requirement): an
append-only-per-attempt-directory plus a CAS-guarded generation-pointer
file, built on the existing `withFileLock`/atomic-rename primitives
(#287/#448) — not a new SQLite dependency. This keeps the globally
`npm install -g`'d CLI free of native-binding/prebuilt-binary platform risk
(the same reasoning behind every other zero-new-deps decision in this
project).

**Wiring in this PR**: `sdlc_build_next`'s claim begins the attempt
(`CLAIMED`, with a lease); `sdlc_validate` commits `BUILT` → `VALIDATING` →
the terminal verdict, copying `builder.json`/`validation.json` into the
attempt directory as retained evidence. The orphan reclaimer
(`pipeline-run.js`) now checks the ledger's lease expiry first — a
prompt-present claim whose worker vanished is reclaimed even though the old
file-presence heuristic would never have caught it — and falls back to that
legacy heuristic only when a task has no ledger entry at all (a run from
before #481, or one whose first attempt hasn't reached the ledger yet, keeps
working unchanged).

**Honest scope note**: the CAS (`expected` version/attempt_id/claim_nonce)
check is strictly enforced for transitions on an *existing* attempt (used by
the BUILT/VALIDATING/terminal commits above). A *fresh* `beginAttempt` call
is deliberately **not** CAS-guarded against whatever the ledger previously
held for that task — not every mutation path in this harness routes through
the ledger yet (a guardrail hard-block, a resumed/reset task, or any
pre-#481 code that edits `tasks.json` directly all leave a prior attempt's
ledger entry non-terminal without telling this module), and a strict check
would refuse a legitimate retry the moment any untracked path touched
`task.status`. The real mutual-exclusion boundary for "two live claims of
the same task" is `sdlc_build_next`'s own `withFileLock(tasksPath, ...)`,
which `beginAttempt` is always called from inside. Retrofitting every
mutation path (Scientist/validator commits included) through this ledger as
the sole source of truth — at which point the fresh-claim path can tighten
back to a real CAS check — is the coordination point with #482. Similarly,
outbox-driven delivery is wired and tested end-to-end for the terminal
verdict commit, but the full existing post-lock side-effect cascade
(events/metrics/evidence/rollup/notifications) is intentionally **not**
rewritten onto it in this PR — that's a separate, large refactor against
already-hardened, heavily-tested code, better done incrementally once the
ledger foundation is stable rather than alongside its introduction.

## Hook system — host observability, context, notifications & status line (#227/#251/#255/#257)

RStack exposes five framework-neutral CLI verbs that any host harness wires into its lifecycle hooks (plus the top-level `statusLine` command). They share one iron contract: **only `guard` can block; every other verb ALWAYS exits 0, never throws, no-ops (or degrades to a safe line) when there is nothing to do, and never emits secrets.** A hook can never disrupt the session it observes.

| Verb | Host hook (Claude Code) | Blocks? | Writes / does | No-op when |
|---|---|---|---|---|
| `rstack-agents guard` | `PreToolUse` | **Yes (exit 2)** | Classifies the pending tool call (destructive gate + validator sandbox); reuses the harness classifier — zero duplicated logic | fails open only on truly unclassifiable input |
| `rstack-agents observe` | `PostToolUse`, `PostToolUseFailure`, `SubagentStart`, `SubagentStop`, `PreCompact`, `Stop`, `SessionEnd` | No | Appends a normalized event to the active run's `events.jsonl` | no active run |
| `rstack-agents context` | `SessionStart`, `UserPromptSubmit` | No (can't) | Emits `{"hookSpecificOutput":{...,"additionalContext":"..."}}` — a structural RStack packet (run id + stage, pending approvals + open decisions, orchestrator pointer), capped ~1KB | no active run (emits nothing) |
| `rstack-agents notify-hook` | `Notification` | No | Forwards the host message to configured channels via `notifications/router.js` (`notifyAll`) | no channel configured |
| `rstack-agents statusline` | `statusLine` (top-level settings key, not a hook) | No | Prints ONE status-bar line — `⬡ rstack  <model>  <stage>  ✔<approved>/⧗<pending>  ◇<decisions>` | no active run (degrades to a minimal `⬡ rstack  <model>  <cwd-basename>` line) |

### observe — normalized event vocabulary

`normalizeObservation` (`src/commands/observe.js`) maps a `hook_event_name` (or Pi-style `type`) to a normalized event, redacts secrets, and truncates values (~1200 chars). Events written:

| Source hook | Normalized event | Extra fields |
|---|---|---|
| `PreToolUse` | `tool_call` | `tool`, `input` (sanitized) |
| `PostToolUse` | `tool_result` | `tool`, `isError`, `summary` |
| `PostToolUseFailure` | `tool_result` | `isError:true` (default even without an explicit flag) |
| `SubagentStart` / `SubagentStop` | `subagent_started` / `subagent_stopped` | `agent_type` (optional, redacted) |
| `PreCompact` | `context_preserved` | `trigger` (optional, redacted) |
| `Stop` / `SessionEnd` | `session_shutdown` | — |

Every event carries `{ ts, source, type, ... }` — identical to Pi's shape, so the Business Hub renders host activity like a native Pi run. The dashboard rollups filter by known types, so new event types append to the ledger without disturbing any rollup.

### context — the injected packet

`buildContextString` (`src/commands/context.js`) composes ONLY from facts RStack generates (a run id, a canonical stage id, integer counts, a static pointer). It never reads prompt text, tool inputs, or decision question text, so there is no path for a credential to reach the model through this hook. `readPipelineState` supplies `current.stage_id`; `readApprovals`/`pendingApprovals` and `readDecisions`/`summarizeDecisions` supply the blocker counts. Any single read that fails is simply omitted — a missing decisions file never sinks the packet.

### notify-hook — the relay

`runNotifyHook` (`src/commands/notify-hook.js`) parses the host `{message,title}`, redacts + truncates it, and calls `notifyAll` (already fire-and-forget with per-channel error capture and a bounded timeout). It short-circuits to a silent no-op when `hasConfiguredChannels` is false — no parse, no network — so a user without notifications configured pays nothing.

**Email approval notifications (#353)** ride the same router as a sixth channel: the approval-blocked notify sites (claim-gate block, guardrail block, #274 validate-time exhaustion) pass an additive `meta: { kind: 'approval_required', run_id, task_id, artifacts, stage_ids, reason }` that existing webhook channels ignore byte-for-byte, while the `email` channel resolves it through `.rstack/notifications.json` `recipients` (role → `{name, email}`) + `routing` (exact artifact → kind wildcard → canonical stage id, with `policy.json` `managers[]` as the unrouted fallback) and sends one ACS Email REST call per person (To only, HMAC-SHA256 access-key signing via `node:crypto` — zero deps, same #291 socket-timeout hardening). The access key lives ONLY in `RSTACK_ACS_CONNECTION_STRING` (credential-shaped keys in notifications.json are a hard #151 validation error); the channel activates only when both the env key and `channels.email.sender` exist, the sender NEVER throws (a failed email logs to stderr and returns a status string — notification, never a second gate), and the email deep-links to the Business Hub approvals page where the audited, token-verified path records the actual approval.

**Audio notifications (TTS) are deliberately NOT bundled.** RStack ships no ElevenLabs/OpenAI-audio/`pyttsx3` client — spoken alerts are a personal dev-experience nicety, not governance, and audio SDKs would bloat the package. Instead a user wires their own TTS script as a *second* `Notification` hook alongside `notify-hook` (both receive the same JSON on stdin); the pattern is documented in `docs/integrations/claude-code.md`.

### statusline — the status bar

`buildStatusLine` (`src/commands/statusline.js`) composes ONLY from facts RStack generates (a run id, a canonical stage id, integer counts) plus the host-supplied model display name and cwd basename. It never reads tool inputs, file contents, or decision question text, so no credential can reach the terminal through it. It reuses the exact resolver + readers `context` uses — `resolveRunId`, `readPipelineState` (`current.stage_id`), `readApprovals`/`approvalSummary` (approved + pending), `readDecisions`/`summarizeDecisions` (open decisions). Every read is best-effort (a failure drops that segment, not the line), every segment is truncated, and any failure falls back to the minimal line — the command ALWAYS prints exactly one line and ALWAYS exits 0, because Claude Code runs it on every render tick. `parseSessionInput` reads `model.display_name` (or a bare-string model) and `cwd`/`workspace.current_dir`, tolerating any junk stdin.

### Other harnesses

The **Tau** adapter (`src/integrations/tau/rstack_sdlc.py`) wires the same coverage on Tau's hook model — `tool_call`/`tool_result` (observe + guard), `tool_execution_failure` (error `tool_result`), `before_compaction` (`context_preserved`), and `before_agent_start` (context injection into the turn's system prompt) — all fire-and-forget except the timeout-bounded context fetch. Tau exposes **no delegated-subagent event and no notification event**, so those are intentionally not wired there (documented in the adapter). Other harnesses wire the same verbs per `docs/integrations/wire-your-own-harness.md`; `rstack-agents doctor` reports which hooks are live per framework.

### Quality gates — opt-in discipline presets (#256)

Distinct from `guard` (always-on safety), `rstack-agents gate <name>` is an **opt-in** layer of opinionated PreToolUse presets that enforce spec-first / test-first / in-scope discipline at the terminal. **OFF by default** — a team wires only the presets it wants via `init --gates plan,tdd,scope` (or `.rstack/rstack.config.json` `hooks.gates`). They complement the harness DOR/decisions; they never replace them.

| Preset | Trigger | Verdict | Overridable |
|---|---|---|---|
| `plan-gate` | editing a source file with no recent `.spec.md` (14d) AND no active RStack run+plan | WARN (exit 0) | n/a — never blocks |
| `tdd-gate` | writing/editing PRODUCTION code (source ext, not a test/config/migration/dto/infra/docs file) with no matching test file | **BLOCK (exit 2)** | `RSTACK_ALLOW_NO_TESTS=1` OR an audited `no-tests:<taskId>` / `guardrail-override:<taskId>` approval (the #133 trust path) |
| `scope-guard` | a file outside the active spec's declared "Files to create/modify" scope | WARN (exit 0) | n/a — never blocks |

Iron rules (mirroring the observe/context contract): **only `tdd-gate` ever exits 2**, and it is **always overridable — never a dead-end**. Any unknown gate name, unclassifiable/malformed input, non-file tool (e.g. Bash), or internal error fails **OPEN** (exit 0). Implementation: `src/commands/gate.js`; `classifyProductionCode` ports the reference `tdd-gate.sh` skip patterns to precise suffix-based matching (substring matching caused false skips in the shell version). In `init`'s PreToolUse array, `guard` stays first and gate hooks are appended after it (matcher `Write|Edit|MultiEdit`). On Tau, set the `quality_gates` setting / `RSTACK_TAU_GATES` to run the same presets on the `tool_call` hook after guard. `doctor` reports which gates are wired (informational — never a FAIL, since gates are opt-in). Full guide: `docs/integrations/quality-gates.md`.

## Run metrics (metrics.json)

`<run_dir>/metrics.json` is the persisted cost/duration/token rollup for a run (#83, #135). It is written by `updateRunMetrics` (`src/core/harness/run-state.js`) under a file lock with atomic tmp+rename, so concurrent writers both land. Full schema:

```json
{
  "cumulative_duration_ms": 0,
  "cumulative_cost_usd": 0,
  "cumulative_tool_calls": 0,
  "cumulative_tokens": { "input": 0, "output": 0, "total": 0 },
  "stage_elapsed_ms": { "07-code": 900 },
  "stage_status": { "07-code": "PASS" },
  "stage_cost_usd": { "07-code": 0.42 },
  "stage_tokens": { "07-code": { "input": 12000, "output": 3000, "total": 15000 } },
  "context_tokens_used": null,
  "context_tokens_available": null,
  "applied_telemetry_keys": ["<sha256 of the builder contract that was counted>"]
}
```

All fields are additive-tolerant — readers default anything missing, so legacy files need no migration and the file carries no `schema_version`. `cumulative_tokens` doubles as the marker that the run was written by the incremental telemetry path: readers (`resolveRunTotals` in `src/observability/metrics/derive.js`, the reporter's cost summary) treat persisted totals as authoritative when the object is present and recompute from `cost_recorded` events otherwise, so legacy runs still render. Unrelated metrics updates never materialize the marker on legacy files.

Write semantics:

- Top-level `cumulative_*` values and the stage maps passed directly to `updateRunMetrics` **overwrite/merge-per-key** (pre-#83 behavior, unchanged).
- An `increment` block **adds** deltas atomically in-lock: `cost_usd`, `tool_calls`, `tokens {input, output, total}`, and per-stage `stage_cost_usd` / `stage_tokens` maps. This is how `cost_recorded` telemetry updates totals incrementally instead of being re-derived O(events) per dashboard poll.
- `context_tokens_used` / `context_tokens_available` are point-in-time gauges (the context-pressure hook for BLE-6.2), so they overwrite.

Idempotency (double-count guard): an `increment` may carry an `idempotency_key` (a SHA-256 of the canonical builder-contract content, from `builderContractKey`). The whole increment is applied **at most once** per key — consumed keys are recorded in `applied_telemetry_keys` and checked/appended inside the same lock as the totals. This is what stops one real builder execution being persisted 2–3× through the automated retry path (#123) and the goal loop's stage resets (#129): re-validating the *same* `builder.json` (identical content → identical key) is a no-op, while a genuine retry that actually re-runs the builder writes a *new* contract (different content → new key) and correctly counts again. The guard is content-based, not timestamp-based, precisely so a real re-run is never mistaken for a replay; two attempts with byte-identical contracts represent the same spend and are collapsed on purpose. Belt-and-braces, the stale `builder.json` is also removed at re-claim (`sdlc_build_next`) and at goal-loop reset (`resetStagesForRetry`), so a reset stage cannot replay the prior attempt's contract at all — it must produce a fresh one to be validated.

Mid-run upgrade seeding: the first increment to materialize the `cumulative_tokens` marker on a run that already has pre-upgrade `cost_recorded` history seeds the persisted totals from an event recompute (passed as `seed` to `updateRunMetrics`, applied once and only when the marker is being created). Without this, an in-flight run upgrading to the persisted-metrics format mid-run would drop all prior history (e.g. `$5.00` of legacy events + one `$0.10` new validation would report `$0.10`).

`cumulative_tool_calls`: fed by `increment.tool_calls`, sourced from the builder contract's `execution.tool_calls` (total tool **invocations** in the attempt — the guardrail-budget signal). This is distinct from `tools_used_count` (`execution.tools_used.length`, the count of distinct tool **names**); a contract with no `execution.tool_calls` contributes nothing to the counter.

Telemetry source: at validate time `sdlc_validate` extracts the builder contract's structured `cost` / `context` / `execution` fields via `extractBuilderTelemetry` (`src/core/harness/telemetry.js`), on **every** validation — retries cost money too, and the idempotency key (above) is what prevents re-validation of the same contract from double-counting. Non-numeric cost values are ignored by extraction; the contract gate's `builder_v2_cost_values_are_numeric` check is what fails validation on them. Cost and tokens are split evenly across the task's canonical stages (the same normalization as stage elapsed), so multi-stage tasks are never double-counted.

Events (pinned contract, same rules as `retry_decision` — downstream consumers key on these exact shapes):

- `cost_recorded` — `task_id`, `usd` (effective spend: `actual_usd` wins over `estimated_usd`; `cost` kept as a legacy alias), `estimated_usd`, `actual_usd`, `currency`, `tokens`, `input_tokens`, `output_tokens`, `source: "builder_contract"`.
- `context_recorded` — `task_id`, `profile`, `workflow`, `injected_sources` (count), `tokens_used`, `tokens_available`, `source: "builder_contract"`.
- `metrics_write_failed` — `task_id`, `operation` (e.g. `"telemetry_increment"`), `error`. Emitted when a `cost_recorded` event landed but its matching persisted increment failed to write. It marks the persisted totals as behind the events; `resolveRunTotals` detects the event (via `hasMetricsWriteDrift`) and falls back to event recompute rather than reporting a total it knows is stale.

Rollups: the pipeline-state `cost_context` block carries `cumulative_tokens` alongside the existing cost/duration/tool-call totals, and each stage entry carries its `cost_usd` / `tokens` share (`null` when never recorded). `rstack-agents pipeline status` prints the token total; `--json` exposes the full structure.

### Context pressure warnings (#136, BLE-6.2)

`src/core/harness/context-pressure.js` classifies oversized context into **non-blocking** `context_pressure_warning` events. It builds on the #83/#135 telemetry: at validate time `sdlc_validate` measures the builder contract's `memory_summary` / `stage_summaries[]` sizes and its reported `context.tokens_used` / `context.tokens_available` gauges against configurable thresholds. Sizes are approximate character/token signals already in the harness — **no model tokenization dependency** (the issue's explicit constraint). `classifyContextPressure` is pure; the classifier and validators do no I/O (the sole impure export, `loadProjectContextPressureThresholds`, reads `.rstack/rstack.config.json`, mirroring `loadProjectGuardrails`).

Thresholds live under `context_pressure` in `rstack.config.json` (optionally nested under `thresholds`) and are validated field-by-field on load (`validateContextPressureConfig`, wired through `validateProjectConfigs` — #151 pattern), so a bad threshold produces a named warning and the default applies rather than silently disabling a check. Defaults: `builder_prompt_chars` 120000, `injected_memory_chars` 24000, `artifact_summary_chars` 12000, `stage_summary_chars` 8000, `context_tokens_used` 160000, `context_tokens_ratio` 0.85.

**Emit-vs-detect (transparency, #136 rule):** this path is **detect-only** — it warns, it does not prune memory or truncate artifacts. It therefore emits **only** `context_pressure_warning` and **never** `memory_pruned` or `artifact_summary_truncated`, which would name actions this code does not take. (`memory_pruned` is emitted separately by the memory-injection path when it actually prunes; this module does not.)

Event (pinned contract, same rules as `retry_decision`):

- `context_pressure_warning` — `task_id`, `source` (`builder_prompt` | `injected_memory` | `memory_summary` | `stage_summary` | `artifact_summary` | `context_tokens`), `metric` (`chars` | `tokens` | `ratio`), `size` (the measured value), `threshold` (the breached limit), `blocking: false`. Optional `stage_id` (stage-summary source), `artifact` (artifact-summary source), and `tokens_used` / `tokens_available` (ratio metric). Under-threshold context produces **no** event (silence, never a zero-size event).

Rollup: the pipeline-state `context_pressure` block carries `{ total, by_source, warnings[] }`; `summarizePipelineState` exposes the count; `rstack-agents pipeline status` prints a `Context pressure warnings:` line (with the per-source breakdown) only when warnings are present.

## Agent episodic memory

Validator-approved tasks are written to an agent/stage scoped episodic memory store by `src/memory/index.js`.

Default storage is configurable and resolves to:

```text
${RSTACK_HOME:-~/.rstack}/projects/<project-slug>/memory/
  episodes.jsonl
  facts.jsonl
  retractions.jsonl
  retrieval-events.jsonl
```

Override storage without changing code by setting `RSTACK_MEMORY_DIR` or by adding `.rstack/memory-config.json`:

```json
{
  "memory": {
    "backend": "jsonl",
    "retrieval": "lexical",
    "topK": 3,
    "maxInjectedChars": 1800,
    "writePolicy": "validator-approved-only",
    "embeddingProvider": "none"
  }
}
```

Memory is injected into builder prompts only as bounded historical context. It is explicitly non-authoritative and cannot override the current task, user approvals, tool safety, or validator gates.

Every builder prompt asks agents to add compact summary fields to `builder.json`:

```json
{
  "memory_summary": {
    "work_done": "",
    "decisions": [],
    "evidence": [],
    "context_to_keep": [],
    "context_to_drop": [],
    "next_agent_hints": []
  },
  "stage_summaries": [
    {
      "stage_id": "07-code",
      "agent_id": "agent.07-code",
      "work_done": "",
      "evidence": [],
      "context_to_keep": [],
      "context_to_drop": []
    }
  ]
}
```

This is the context-reduction path. Later agents receive durable decisions, evidence, and handoff hints instead of full transcripts or raw logs.

### Write policy (enforced in code, #137)

Memory is only useful if it is trustworthy and bounded, so the trust level of every episode is decided by `appendEpisode` in `src/memory/index.js` (via `evaluateWritePolicy`) — never by trusting the caller's `trusted` flag and never by prompt text. A caller cannot launder a failed or unsafe episode into trusted memory: `appendEpisode` overwrites `trusted` with the policy decision before writing.

Two policies (`writePolicy` in `memory-config.json`, default `validator-approved-only`):

- **`validator-approved-only`** — only `validator_status: "PASS"` episodes are stored, and only as `trusted: true`. A non-PASS episode is **skipped** (nothing is persisted, so it can never be recalled and cannot resurrect the behavior it records).
- **`validation-attempts`** — non-PASS episodes **are** stored, but always `trusted: false`. They surface only when a recall explicitly opts into untrusted memory (`recallEpisodes(..., { includeUntrusted: true })`); default recalls skip them.

Before an episode may be trusted, three integrity checks must also hold (a PASS that fails any of them is demoted to `trusted: false` under `validation-attempts`, or skipped under `validator-approved-only`):

1. **signature** — `verifyEpisodeSignature` matches (tampered records are never trusted; `readEpisodes` also drops them on read),
2. **evidence_paths** — a non-empty array of non-blank strings,
3. **quality_score** — a finite value in `[0, 1]`.

`appendEpisode` returns `{ path, written, trusted, decision }` so the call site can emit the matching ledger event. Retracted episodes (`retractEpisode`) are filtered by `readEpisodes`/`recallEpisodes` and are never served.

**Events (pinned contract — `sdlc_validate` appends one per write decision to `events.jsonl`):**

- `episode_memory_written` — `task_id`, `episode_id`, `trusted`, `write_policy`. The episode was stored at the stated trust level.
- `episode_memory_skipped_untrusted` — `task_id`, `episode_id`, `reason` (e.g. `not-validator-approved`, `integrity-failed:signature`), `write_policy`. The write policy refused to store the episode; nothing was persisted.
- `episode_memory_write_failed` — `task_id`, `error`. An unexpected error (I/O, schema-invalid episode) prevented the write.

Memory is **historical context only**: it is injected into builder prompts as bounded, non-authoritative observations and cannot override the current task, user approvals, tool safety, or validator gates. The injected block says so explicitly (`formatEpisodesForPrompt`), and trusted recall cannot reach a failed or unsafe episode unless the run is explicitly configured to store validation attempts as untrusted memory and the recall explicitly asks for untrusted memory.

## Guardrails

Guardrail defaults live in `src/core/harness/guardrails.js`:

- `maxTaskAttempts: 2`
- `maxDestructiveTaskAttempts: 1`
- `maxToolCallsPerTask: 40`
- `maxMessagesPerTask: 25`
- `requireBuilderContract: true`
- `requireValidatorContract: true`
- `requireEvidenceForPass: true`
- `requireUserApprovalForDestructiveActions: true`
- `requireUserApprovalForPublishDeployOrForcePush: true`

Budgets can be overridden per project in `.rstack/rstack.config.json`:

```json
{
  "guardrails": { "maxTaskAttempts": 3 }
}
```

Invalid override values (negative numbers, non-numeric strings, unknown keys) are ignored and the defaults apply.

### Enforcement

Attempt budgets are enforced at the task claim gate, not just described in prompts. When `sdlc_build_next` selects a task whose recorded `task_started` events already meet the budget (`maxDestructiveTaskAttempts` for tasks marked `destructive: true` or `risk_level: "destructive"`), the task is hard-blocked — stamped `BLOCKED` in `tasks.json` instead of `IN_PROGRESS` — and on that transition:

- a `guardrail_triggered` event is appended to `events.jsonl` with `limit_name`, `current_value`, and `limit_value`,
- a pending `guardrail-override:<task_id>` approval request is queued for the Business Hub,
- configured notification channels are paged.

Repeated claims while the task is already `BLOCKED` return the same guidance without appending duplicate events or re-paging. `BLOCKED` tasks remain claim candidates so an approved override can resume them; the gate re-evaluates on every claim.

Approving the `guardrail-override:<task_id>` artifact (via `sdlc_approve` or the dashboard) permits **exactly one** more attempt: the harness stamps the override `CONSUMED` as soon as the claim succeeds and appends a `guardrail_overridden` audit event, so the next over-budget claim blocks again.

Tool-call and message budgets are checked at validation time from builder contract telemetry (`execution.tool_calls`, `execution.messages`). Overages fail validation with a `guardrail_<rule>` check and emit `guardrail_triggered` events.

The extension also includes the guardrail summary in generated builder prompts so agents see the budgets they are held to.

### Stage-keyed approval gates (#228)

`.rstack/policy.json` supports blanket per-stage human gates alongside the task-keyed
`required_approvals`:

- **`required_stage_approvals`** — canonical stage id → artifact list. Any task whose canonical
  stages (via `taskStageIds`, the same recipe the rollup and goal gate use) include the key must
  see those artifacts APPROVED before it claims — no task ids needed.
- **`approvals.every_stage: true`** — every task requires a `stage-approval:<stage-id>` sign-off
  for each canonical stage it enters. Approving a stage once unblocks all tasks entering that
  stage for the rest of the run (latest-record-wins, run-bound). A task that maps to no canonical
  stage fails CLOSED on `stage-approval:<task-id>` — the blanket promise never silently skips.

Both are explicit team policy, so — like `required_approvals` — they are enforced in every mode,
express included. The derived artifacts merge into the claim gate's required list
(`src/core/harness/stage-approvals.js`) and flow through the same audited approval path (#133,
run binding #298): queue cards, manager paging, and replay rejection come for free.
`validatePolicyConfig` (#151) warns on unknown stage keys — a gate keyed to a typo'd stage would
otherwise never fire.

### Retry policy

Post-validation task transitions are decided by `src/core/harness/retry-policy.js` (#123), not by prompts or inline attempt math. `classifyRetryDecision({ task, validation, events, guardrails })` is a pure function driven by the validator contract's `retry_recommendation`, bounded by the same attempt budgets as the claim gate (`maxTaskAttempts`, or `maxDestructiveTaskAttempts` for destructive tasks; attempts = recorded `task_started` events):

| `retry_recommendation` | Condition | `action` | `next_status` |
|---|---|---|---|
| `none` | validation PASS | `complete` | `PASS` |
| `retry_builder` | attempts < budget | `retry` | `FAIL` (re-claimable by `sdlc_build_next`) |
| `retry_builder` | attempts >= budget | `exhausted` | `BLOCKED` (needs `guardrail-override:<task_id>` approval) |
| `ask_user` | — | `human_context` | `NEEDS_CONTEXT` |
| `block` | — | `block` | `BLOCKED` |
| missing / unknown | conservative fallback | FAIL behaves as `retry_builder`, PASS as `none` | per row above |

The function never throws on malformed input, and returns `{ action, next_status, attempt, max_attempts, reason, issues }` where `reason` is an operator-readable sentence and `issues` is a compact string array (validator issues mapped to `name: evidence`, ~120 chars each, max 5).

On every FAIL validation `sdlc_validate` stamps `task.status = next_status` inside the locked write and appends a `retry_decision` event (task_id, stage_id, attempt, max_attempts, retry_recommendation, action, next_status, reason, issues — a pinned contract for downstream consumers), plus one action-specific event: `task_retry_scheduled` (with the legacy `validation_failed` kept for dashboards), `task_retry_exhausted` (with the legacy `guardrail_triggered` kept for the claim gate and dashboards), `task_human_context_required`, or `task_blocked_by_validator`.

### Validator sandbox

Validators check work — they never modify it. `src/core/harness/validator-sandbox.js` enforces this in code, not just prompts (#119):

- **Context signal**: when `sdlc_delegate` spawns a validator/reviewer/security-role agent (name or id matching `validator|review|qa|security|audit|tester`), it sets `RSTACK_VALIDATOR_CONTEXT=1` (plus `RSTACK_VALIDATOR_RUN_ID` for event routing) on the child Pi subprocess and scrubs both vars from builder-role children. The extension's `tool_call` hook reads the flag inside the child.
- **Denied action classes**: write/edit-style tools; destructive shell commands (`rm`, `mv`, `chmod`, in-place `sed`, `tee`, ...); git mutations (`push`, `commit`, `reset`, `checkout`, ...); publish/deploy/force-push commands (`npm publish`, `terraform apply`, `kubectl delete`, `gh pr merge`, ...); destructive SQL; and shell redirects into protected secret paths (`.env`, key files, credentials).
- **Read-only default tools**: validator-role delegations default to `read, grep, find, ls, bash` when the caller passes no explicit `tools` — bash stays available so validators can run tests, with mutating commands denied at command level.
- **Events**: each blocked mutation appends a `validator_sandbox_denied` event (tool name + reason) to `events.jsonl`. Allowed reads are not logged unless `RSTACK_VALIDATOR_SANDBOX_DEBUG=1` opts in (`validator_sandbox_allowed_read`), so events.jsonl never floods.
- **No escape hatch**: the sandbox is checked before the builder-oriented gates and is not bypassable via `RSTACK_ALLOW_DESTRUCTIVE` or destructive-action approvals. Builder contexts (env var unset) are completely unaffected. Human-approved exceptions are out of scope by design.

### Retry visibility

Every retry decision is observable without reading source (#125):

- **Events**: `sdlc_validate` appends `retry_decision` plus the action-specific event — `task_retry_scheduled`, `task_retry_exhausted`, `task_human_context_required`, or `task_blocked_by_validator` — each carrying `task_id`, `stage_id`, `attempt`, `max_attempts`, `retry_recommendation`, an operator-readable `reason`, and compact `issues[]`.
- **Trace**: `sdlc_trace` renders retry lines with attempt counters, e.g. `↻ retry scheduled 1/2 — 004-implementation: validator found missing evidence` and `⛔ retries exhausted (2/2) — blocked pending guardrail-override`.
- **Pipeline state**: the rollup's `retries` summary carries `{ total, scheduled, exhausted, human_required }`, and each failed stage carries `retry_state: "retryable" | "exhausted"` so `rstack-agents pipeline status` can distinguish "re-run the builder" from "approve the override".
- **Feed**: the Business Hub live feed renders the four `task_retry_*` events with distinct levels (warn / fail / blocked).

### Resume-aware runner

`rstack-agents pipeline run` advances a run from its current harness state without invoking any model (#124): completed tasks are skipped, an active task with a builder contract is validated (which drives the retry policy), retryable failures are re-claimed through `sdlc_build_next`, and the loop stops the moment a human is needed — pending approval, `ask_user`, an exhausted retry budget awaiting a `guardrail-override`, a prepared builder packet awaiting agent execution, or `--max-steps`. `--dry-run` prints the exact next action and persists nothing (not even the rollup); `--json` emits the structured step report. Human-gate stops exit non-zero so CI can tell "needs a human" from "complete".

### Goal loop (bounded)

BLE-4 (#127/#129) adds the goal-conditioned loop: "keep working until a structured success
condition passes" — bounded, budget-capped, and model-free.

**Goal contract.** A goal definition (per-run `goal.json`, or a recipe file passed via
`pipeline loop --goal <path>`; see `docs/loop-recipes.md`) declares `goal_id`, `min_score`
(0–100, default 100), and `criteria[]`. Criterion kinds, all evaluated by
`src/core/harness/goal-check.js`:

- `file_exists` — path relative to the project root (`run_relative: true` for the run dir).
- `command` — runs in the project root; passes when the exit code matches `expect_exit_code`
  (default 0). Bounded by `timeout_ms` (default 120s, cap 600s). Must be a read-only check —
  the evaluator runs it in `--dry-run` too.
- `metric_threshold` — numeric dot-path (`metric`) compared (`operator`: `>= > <= < == !=`)
  against `value`, read from `source`: `"feedback"` (the agent-11 artifact), `"pipeline_state"`
  (the in-memory rollup), or `{"file": "relative/to/run"}`. A missing feedback artifact is a
  clear non-pass that recommends rerunning `11-feedback-loop` — never a silent skip.
- `judge` — **the harness never calls a model.** Judge criteria close through the verdict
  protocol: `<run_dir>/goal-verdict.json`, written by a host framework or a human (or the
  evidence-gated agent-11 `goal_evaluation` path described below):
  `{ "criterion_id", "verdict": "PASS"|"FAIL", "judge", "reasoning", "iteration",
  "recommendation": "retry"|"block", "recommended_rerun_stages": [] }` (single object, array, or
  `{"verdicts": []}`). The harness validates and consumes it; without a fresh verdict the
  evaluation stops at `ASK_USER`. Freshness: inside a loop iteration a verdict must carry
  `iteration >= current` — an older or **missing** iteration stamp is stale, so a write-once
  verdict can never auto-pass later re-evaluations (one-shot evaluations outside the loop accept
  unstamped verdicts). A FAIL verdict retries the named stages; `"recommendation": "block"` stops
  for a human.

Any criterion may carry `rerun_stages` — the canonical stages a RETRY should reset.

**Agent-11 writer path (#128).** Stage 11 (`11-feedback-loop`) is a legitimate writer of the same
verdict protocol — not a second one. Its feedback.json may embed a structured `goal_evaluation`
section (`goal_id`, `iteration`, `status`, `consistency_score`, `critical_count`,
`failing_stages`, `recommended_rerun_stages`, `requires_human_decision`, `reason`, and
per-criterion `criteria[]`: `{ criterion_id, result: "met"|"not_met"|"unknown", evidence[],
reasoning, recommended_rerun_stages, maintenance_category, recommendation }`). The evaluator
converts each per-criterion result into a judge verdict **only when every listed evidence path
resolves to a real file inside the run dir or the project root** (relative paths resolve against
the run dir, then the project root; `..` traversal, `.`, directories, and absolute paths outside
those roots are rejected) — an `unknown` result or an unevidenced claim is rejected with a
recorded reason and the criterion stays at the `ASK_USER` path. Be honest about what this gate
buys: it checks evidence **existence**, not **relevance** — whether the named artifact actually
proves the claim remains the validator's and the human's job. The same freshness rules apply: inside a loop iteration an
evaluation stamped with an older or missing `iteration` is stale and ignored — and because this
writer is model-driven (unlike the trusted `goal-verdict.json` writer), a stamp **ahead of** the
current iteration is rejected as malformed rather than staying fresh forever. An explicit
`goal-verdict.json` entry outranks the agent-11 evaluation for the same criterion — including the
id-less single-judge shorthand — so a human or host verdict always wins.
`recommended_rerun_stages` on a `not_met` criterion feed stage resets, routed through the agent's
maintenance taxonomy (corrective defects → the fixing stage, preventive gaps → docs, and so on).
Reset semantics differ by writer: an explicit `goal-verdict.json` **replaces** the criterion's
`rerun_stages` (the human names exactly what to reset), while an agent-11 verdict **unions** with
them — the agent can add stages but can never drop the recipe's wiring (e.g. `11-feedback-loop`
kept in `rerun_stages` so each iteration re-runs the reviewer and refreshes the stamp). The top-level `goal_evaluation` fields are the agent's
recommendation for hosts and dashboards; the evaluator recomputes its own decision from raw
evidence and never copies them. Section shape is checked by `validateGoalEvaluation` (same
`{ok, checks, issues}` contract style as builder/validator checks), consumption/rejection is
surfaced on the evaluation as `agent_goal_evaluation: { present, consumed, rejected, issues }`,
and the harness still never calls a model — agent 11 recommends with evidence; `goal-check.js`
decides.

**Stage-11 validation gate (#196).** The same shape check is enforced at validation time, not just
at loop time: `validateStageGoalEvaluation` (goal-check.js) runs inside `sdlc_validate` — which the
model-free `pipeline run`/`pipeline loop` bridge also drives. When a goal is active for the run — a
`goal.json` in the run dir, or pinned loop events (`loop_iteration_started`/`goal_evaluated`)
proving a `pipeline loop --goal <recipe>` context — a task targeting `11-feedback-loop` FAILs
validation when feedback.json is missing or its `goal_evaluation` section is malformed, with the
named checks recorded in validation.json instead of a silent ASK_USER later. Runs with no active
goal keep the section optional (a single informational `goal_evaluation_not_required` PASS), and
tasks that never target stage 11 see no goal checks at all. Goal-activity is **permanent** — any
historical `loop_iteration_started`/`goal_evaluated` event marks the run goal-driven for the rest
of its life, so a later unrelated stage-11 revalidation still demands `goal_evaluation`
(conservative by design). And it is **fail-closed on unreadable state (#200)**: if `events.jsonl`
exists but yields zero parseable events, or can't be read at all, the gate returns a
`goal_activity_indeterminate` FAIL rather than assuming "no goal" — a corrupt event log at stage 11
stops for human eyes instead of silently passing.

**Evaluator.** `evaluateGoal(projectRoot, runId, options)` builds the rollup in memory (persists
nothing), reads only structured JSON (never prose), always layers harness checks over the criteria
(pending approvals, pending decisions, NEEDS_CONTEXT tasks, guardrail-BLOCKED tasks, unfinished
tasks, critical feedback issues), and returns `{ status: PASS | RETRY | ASK_USER | BLOCK, score,
min_score, critical_count, failing_stages, recommended_rerun_stages, reason, criteria,
harness_checks }`. Precedence is deterministic: humans first (`ASK_USER` — approvals, decisions,
context, missing judge verdicts), then blocking issues (`BLOCK` — blocked tasks, unremediable
criticals, judge blocks), then retryable work (`RETRY`), then `PASS` (everything green and
`score >= min_score`). Critical feedback issues whose `remediation.agent_to_rerun` maps to a
canonical stage become RETRY targets; criticals with no remediation path are BLOCK.
`summarizeGoalDecision(evaluation)` renders the one-line operator view.

**Loop runner.** `rstack-agents pipeline loop` runs: one resume-aware pipeline pass (the same
model-free engine as `pipeline run`) → goal evaluation → decision. On RETRY it resets **only** the
recommended stages' tasks to PENDING (in-lock, atomic, original file shape preserved; IN_PROGRESS,
NEEDS_CONTEXT, and BLOCKED tasks are never reset, and attempt budgets still count historical
`task_started` events, so a reset can never launder attempts past the claim gate) and goes again.
Three independent brakes, enforced in `src/core/harness/goal-loop.js`, not in prompts:

1. **Iteration bound** — default 3 (`--max-iterations` or `.rstack/rstack.config.json`
   `loop.maxIterations`), hard cap 20 that no config or flag can exceed.
2. **No-progress stop** — an iteration that leaves task statuses and the goal evaluation
   identical (or a RETRY naming no stages) stops as `no_progress` instead of repeating itself.
3. **Budget cap** — `.rstack/budget.json` `run_budget_usd` against the run's
   `cumulative_cost_usd`, checked before every iteration.

Human gates from the pipeline pass (`pending_approval`, `ask_user`, `blocked_retry_policy`,
`missing_contract`) propagate and stop the loop. `--dry-run` reports iteration 1's evaluation and
decision and persists nothing — no events, no resets, not even the rollup. Only `complete` and
`dry_run` exit zero, so CI can tell "goal met" from everything else.

**Events (pinned contract, one per loop decision):** `loop_iteration_started` and `goal_evaluated`
each iteration (the latter carrying `status`, `score`, `critical_count`, `failing_stages`,
`recommended_rerun_stages`, `reason`), `loop_iteration_retrying_stages` (`stages`, `task_ids`) on
every reset, and exactly one terminal `loop_completed` (goal met) or `loop_blocked` (`stopped_on`:
`ask_user | blocked | max_iterations | no_progress | budget_exhausted` or a propagated human gate).
All appended to `events.jsonl` under the same file lock as the evidence ledger, and rendered by the
Business Hub feed.

### Critical-stage checkpoints (#132, BLE-5.2)

Loop retries mutate stage artifacts, so the stages where a bad rewrite is expensive get restore
points enforced by `src/core/harness/checkpoints.js` — in code, never prompt text. The critical set
defaults to `06-architecture`, `07-code`, `08-testing`, `09-deployment`,
`12-security-threat-model` and is configurable via `.rstack/rstack.config.json`
`checkpoints.critical_stages` (canonical stage ids only — plan task ids like `007-code` are
rejected, the exact conflation that silently broke checkpoints before; entries are validated
field-by-field on load like every other config, and an explicitly empty list disables
critical-stage checkpoints).

**Lifecycle.** When `sdlc_build_next` claims a task targeting a critical stage, the harness saves a
checkpoint of `artifacts/stages/<stage-id>/` to `checkpoints/<stage-id>/` **before** the builder
mutates anything — this is the state a failed retry rolls back to. After `sdlc_validate` passes,
the slot is overwritten with the validated artifacts. One slot per stage, last save wins;
save/restore of the same stage serialize on a per-stage lock (same `withFileLock` discipline as
tasks.json).

**No best-effort claims.** Restorability is always verified against the checkpoint directory on
disk (`verifyStageCheckpoint`), never inferred from events or memory: checkpoint events are only
emitted after the directory is verified to exist, the per-stage `checkpoint_restorable` flag in
`pipeline-state.json` is re-checked at rollup time, and `sdlc_rollback` returns a pinned status —
`SUCCESS` (restored), `NO_CHECKPOINT` (nothing on disk, nothing modified), `INVALID_STAGE`
(non-canonical stage id, rejected before touching disk), or `CORRUPT` (a checkpoint exists but
fails its integrity manifest — a deep sha-256 content check — so nothing is restored and the live
stage is left untouched). Note the `pipeline-state.json` `checkpoint_restorable` rollup flag is a
lighter (size-only) check for status display; `sdlc_rollback` always runs the full deep-hash
verification before restoring, so a same-size-tampered slot can read restorable in `status` yet
correctly return `CORRUPT` on an actual rollback — the action fails closed.

**Events (pinned contract):** `stage_checkpoint_before_saved` (`stage_id`, `task_id`, `verified`)
at claim, `stage_checkpoint_after_saved` (same fields) after a PASS validation, and
`stage_checkpoint_reverted` (`stage_id`) on a successful rollback. Unknown types throw
(`checkpointEvent`), same discipline as `LOOP_EVENT_TYPES` and `retry_decision`. The legacy
`stage_checkpoint_saved` event still fires for every canonical stage a PASS task produced
(existing consumers key on it); the three pinned events are the critical-stage contract and the
only ones the `checkpoints` rollup in `pipeline-state.json` (and `rstack-agents pipeline status`)
counts.

## Parallel-execution benchmark (#159)

Builder/validator round-trips dominate wall clock. Some stages are
*data-independent* — none reads another's output artifact — so they can run in
a parallel group. Parallel groups are enabled **from evidence, not vibes**: a
benchmark measures sequential vs parallel wall clock, and the config gate only
flips them on when the measured improvement clears a target (default **≥ 40%**).

**Decision logic** lives in `src/core/harness/parallel-benchmark.js` (pure, no
clock reads — timings are inputs, so the gate is deterministic and tested):

- `checkDataIndependence(members)` — a group is parallel-safe only if every
  member is a canonical stage id, ids are unique, no member reads an artifact
  produced by another member, and the group is within `PARALLEL_GROUP_HARD_CAP`
  (6). An oversized group is **rejected, not silently truncated**.
- `aggregateSequentialTime` / `aggregateParallelTime` — sequential = sum of
  durations; parallel = the slowest member per group (groups run in series),
  plus any solo stages.
- `evaluateParallelGate({ seqTimeMs, parTimeMs, target })` — improvement =
  `(seq − par) / seq`; `enable` is true iff `improvement ≥ target` (inclusive).
- `buildBenchmarkArtifact(...)` — the run-artifact shape.

**Runner:** `node scripts/bench-parallel.mjs [--target 0.4] [--out <path>]
[--run-id <id>] [--timings '{"12-...":900}']`. By default it benchmarks the
data-independent group **12 (security) / 13 (compliance) / 14 (cost)** — each
reads upstream artifacts (requirements, architecture, code) and writes its own
distinct output, so none reads another's artifact.

**Honesty — mock vs real:** the runner defaults to `mode: "mock"`. It does
**not** launch live builder/validator agents; it runs a synthetic sleep
workload per stage (durations modelling round-trip wall clock) timed with the
real clock, and gates on the modelled sum-vs-slowest numbers so CI is not flaky
on scheduler jitter. The artifact is stamped `"mode": "mock"` with a
`measurement` note. Feed real per-stage durations via `--timings` to gate
against measured numbers; live-agent capture (`mode: "real"`) is future work.

**Artifact → Business Hub:** the result is written to
`.rstack/runs/<run_id>/artifacts/parallel-benchmark.json`. The dashboard run
indexer (`state/runs.js → indexArtifacts`) picks up any top-level file under a
run's `artifacts/` as a run-scoped deliverable, so the data reaches the Hub
artifact index with no extra wiring. A dedicated Hub panel is a later,
presentational step — the data flows now.

**Config gate** (`.rstack/rstack.config.json`, validated on load by
`config-validation.js`):

```json
"parallel_groups": {
  "enabled": false,
  "target": 0.40,
  "require_benchmark": true,
  "groups": [["12-security-threat-model", "13-compliance-checker", "14-cost-estimation"]]
}
```

`enabled` should only be set `true` once a benchmark artifact shows the group
clears `target`. Config validation flags non-data-independent groups, an
out-of-range `target`, unknown keys, and `enabled: true` with no groups.
`require_benchmark` is **declared intent, not yet enforced** — its type is
validated, but nothing today blocks `enabled: true` without a benchmark artifact
because the runner is still sequential (the gate is a recommendation). Enforcing
it belongs with the parallel-execution wiring (#208).

## Validation commands

Run these after Harness changes:

```bash
cd /Users/richardsongunde/projects/SDLC-rstack
npm test
npm run validate
```

Also run lint for code-level checks:

```bash
npm run lint
```

## Safety notes

The Harness foundation does not add auth, payment processing, PII storage, public APIs, deploy automation, or npm publishing. Publishing, deployment, force-push, and destructive cleanup still require explicit user approval.

## Attempt identity and exact destructive-action binding (#482)

Two convergence gaps the audit named — "an approval can authorize a
different destructive action on the same task" and "validators verify a
current claim exists, not that the contract was produced under that exact
claim" — closed for the pieces that are safely shippable without a much
larger snapshot-manifest subsystem (see the honest scope note below).

**Exact destructive-action approval binding** — `destructiveActionEnvelope()`
in `src/core/harness/destructive-actions.js` hashes the exact
`run_id`/`stage_id`/`task_id`/`attempt_id`/`category`/normalized
command-or-target into `action_sha256`; `destructiveApprovalArtifact(taskId,
envelope)` produces `destructive-action:<taskId>:<hash-prefix>` instead of
the old bare `destructive-action:<taskId>`. A human approving one specific
destructive command no longer silently authorizes every OTHER destructive
command the same task later attempts — a different command, or the same
command on a different attempt, computes a different hash and needs its own
approval. Omitting the envelope (existing callers not yet threading attempt
identity through) falls back to the legacy task-only artifact unchanged.

The matched approval is now **consumed one-shot** at the point it's used
(`src/integrations/pi/rstack-sdlc.ts`'s `tool_call` guard hook appends a
`CONSUMED` record, same pattern as the existing guardrail-override
consumption) — replaying the exact same approved command a second time
requires a fresh approval. The separate, explicit run-wide `destructive-
action` coarse override (a human consciously unblocking everything
destructive for the run) is untouched — that is a different, intentional
design decision, not the vulnerability this closes.

**Attempt-identity envelope on builder contracts** —
`evaluateAttemptIdentity()` in `src/core/harness/attempt-ledger.js` checks
an optional `attempt_identity: {run_id, stage_id, attempt_id, claim_nonce}`
block on `builder.json` against the CURRENT claim at `sdlc_validate` time.
Absence WARNs (non-blocking — existing builder prompts don't emit this
field yet, and blocking on absence would brick every existing contract
shape); a **mismatch on any present field always FAILs**, regardless of
policy, since a wrong value is positive evidence of a replayed or forged
contract, not an absence-of-metadata gap. Closes "replay a builder contract
from another claim or attempt."

**Honest scope (deliberately deferred, matching the #481/#480 precedent)**:

- The issue's full **content-addressed input-snapshot manifest** (a
  Merkle-style hash of every predecessor contract/artifact, repo file,
  prompt, and policy version consumed by a stage, with symlink-safe
  path/size/mode/sha256 tracking, and Builder/Scientist/Validator consuming
  that immutable snapshot instead of the live workspace) is a separate,
  much larger subsystem — snapshot storage/GC, defining what "the input
  surface" even means per stage, and CAS against snapshot *generation* on
  top of the #481 ledger's own CAS. Not attempted here.
- The builder prompt template does not yet instruct builders to populate
  `attempt_identity` — the verification logic is complete and tested, but
  adoption is a prompt-only follow-up, deliberately not touched in this PR
  given the size and sensitivity of that single template string.
- Scientist and validator contracts do not yet carry/verify
  `attempt_identity` (#480's external-validator contracts still bind on
  `task_id` alone). A natural next slice once attempt-identity proves out
  on builder contracts.
- **In-process vs. out-of-process, named explicitly per the issue's own
  follow-up comment**: the attempt-identity envelope and the destructive-
  action hash are computed and verified by the SAME host process a
  compromised builder controls. This is in-process defense-in-depth against
  a compromised *agent/subprocess* replaying stale data — it is NOT a
  trust boundary against a fully compromised host process, which could
  compute a fully internally-consistent forged envelope. The same
  same-host/compromised-process limit already accepted for #369's approval
  HMAC signing applies here; closing it needs a genuinely out-of-process
  verifier (CI runner, remote attestation, separate-UID service), out of
  scope until #486's multi-tenant scheduler exists.

## Prompt budget, role separation & handoff provenance (#483)

Three convergence gaps the audit named, scoped down to what's safely
shippable this slice (adapted to what #482 actually shipped, not the full
snapshot-manifest design the issue originally assumed — see the honest scope
note below).

**Role separation** — `coreAgentContext()` in
`src/integrations/pi/rstack-sdlc.ts` used to unconditionally embed
`agents/core/validator.md` (the validator's full role file — who it is, what
it checks, how it judges) into EVERY caller's context, including the
builder's own prompt: the agent whose work is being judged could read the
exact rubric it will be judged against. `coreAgentContext(projectRoot, role)`
now takes `role: "builder" | "orchestrator"` (default `"orchestrator"`,
unaffected); `role: "builder"` omits `validator.md` entirely.
`builderPrompt()` passes `"builder"`. The orchestrator keeps full visibility
— it coordinates every role and never executes untrusted work or renders a
verdict itself, so it has no leak to close. (A static one-line resource-path
listing in `orchestrator.md` — `agents/core/validator.md — read-only
verification of builder output` — still names the file's existence in both
contexts; that's benign documentation of the repo layout, not the rubric
leak this closes, and stays unchanged.)

**Model-aware prompt budget** — `src/core/harness/prompt-budget.js`
(`computePromptBudget(tier)`) replaces the old flat, guessed constants
(`PRIOR_STAGE_TOTAL_CAP = 3500`, `PRIOR_STAGE_MAX = 6` prior stages,
regardless of which model would execute the task) with a budget computed
from the model_policy tier that will actually run the task
(`task.budget_envelope.model_policy.builder`). Per the repo's zero-new-deps
precedent (#481), this is a documented character approximation (~4
chars/token) — no tokenizer dependency — keyed to the tier vocabulary this
codebase actually has (`'strong' | 'balanced' | 'economy'`, see
`budgetPolicyForProfile` in `src/core/profiles.js`); there is no per-model
context-window registry or concrete model name available at prompt-assembly
time, so tiers are the honest unit, not invented model names. An
unknown/missing tier resolves to the smallest documented window (`economy`)
— safe by construction, never over-budgets on an unrecognized value.
`priorStageInputsBlock`'s total character cap is now
`min(ceiling, max(floor, remainingInputChars × 0.15))` — floored at the
pre-#483 constant (never less generous than before) and ceilinged so this one
prompt section can never consume the whole window even on the largest tier.
The fixed 6-stage count cap is gone; the char budget is what bounds
inclusion, so a stronger tier genuinely sees more prior-stage history.

**stage_summaries schema_version** — `evaluateStageSummarySchemaVersion()` in
`src/core/harness/contracts.js` follows the #482 `evaluateAttemptIdentity`
WARN-on-absence pattern exactly: a `stage_summaries[]` entry missing
`schema_version` (`STAGE_SUMMARY_SCHEMA_VERSION`, currently `1`) emits a
non-blocking `stage_summary_schema_version_missing` event at validate time
(same pattern as `context_pressure_warning`) — it is never folded into the
FAIL-capable `checks[]` array, since blocking on absence would brick every
contract written before this field existed. The builder prompt template now
instructs builders to include it on every entry.

**Honest scope (deliberately deferred, matching the #481/#480/#482
precedent)**:

- **Dependency-closure-based handoff selection** is NOT implemented. The
  issue's design assumes a per-stage dependency graph ("only the specific
  prior stages THIS stage's contract actually depends on"); no such graph
  exists anywhere in this codebase today (confirmed by direct inspection
  before scoping this PR), and inventing one is a separate, much larger
  design exercise (it needs an authoritative source of per-stage
  dependencies, not a guess). What shipped instead: the existing
  foundational-stages-plus-nearest-preceding ordering is kept, but it is no
  longer capped at an arbitrary count — a stronger tier's larger budget
  naturally includes more of the true predecessor set. True closure
  selection is future work once a dependency graph exists.
- **No real tokenizer** — the char-approximation is an intentional,
  documented trade-off (zero-new-deps precedent), not a precise count. A
  budget computed this way can still be off by a meaningful margin for any
  given model's real tokenizer; the safety-margin ratio (0.15) exists
  specifically to absorb that.
- **No content-hash/full snapshot-manifest binding** for handoff
  provenance — ties directly to #482's own deferred snapshot-manifest scope
  note above; not re-attempted here.
- **No typed `CONTEXT_BUDGET_EXCEEDED` blocking result** — a stage whose
  mandatory content (contract + scope + acceptance criteria, never the
  optional prior-stage digests) somehow can't fit the computed budget is not
  given a hard-blocking error type. Given the operational risk of a new
  blocking failure mode on a template this central, this stays a soft
  signal (the same non-blocking event pattern used throughout this section)
  rather than a new way for a run to get stuck.
- **No formal prompt-injection-resistance test battery** for the role-
  separation boundary — the shipped test (`tests/builder-prompt-critique-
  446-451.test.js`) proves the validator's role file is absent from the
  builder prompt; it does not attempt adversarial injection scenarios
  (e.g. a stage artifact crafted to smuggle rubric-shaped text past the
  exclusion). Deferred as a distinct, larger effort.

## Destructive-action classification (#131, BLE-5.1)

`src/core/harness/destructive-actions.js` is the centralized, I/O-free source of truth for
what makes a command or write destructive. It replaces scattered, context-private checks with
one classifier both builder-side and validator-context callers can consume.

`classifyDestructiveAction(command | { command } | { toolName, input } | string)` returns a
stable frozen verdict `{ destructive, category, reason, matched }`. Categories
(`DESTRUCTIVE_CATEGORIES`):

- `broad-delete` — recursive/forced `rm`, `rmdir`, `shred`, `mkfs`, `dd of=`, `find -delete`
  (a single-target `rm file.txt` is NOT flagged — recursion/force is what escalates)
- `git-force` — `git push --force`/`-f`/`--force-with-lease`/`+ref`, `git reset --hard`
- `publish` — `npm/yarn/pnpm publish`, `npm unpublish`, `cargo publish`, `gem push`,
  `twine upload`, `gh release create/delete`
- `deploy` — `terraform apply/destroy`, `pulumi up/destroy`, `kubectl apply/delete/...`,
  `helm ...`, `docker push`, CloudFormation stack ops, `firebase/vercel/netlify/fly/serverless
  deploy`, `ansible-playbook`
- `secret-write` — shell redirect/`tee` into `.env`/keys/credentials; write-tool targets on
  secret/credential/key paths
- `protected-config-write` — write-tool targets on `.git`, CI workflows, Dockerfiles,
  lockfiles, `.tf`/`.tfvars`, `.rstack`, `.claude/settings*.json`, `*rstack-hooks.json`
- `db-destroy` — `DROP TABLE/DATABASE/SCHEMA`, `DELETE FROM`, `TRUNCATE`, plus ORM/CLI
  equivalents (`.dropDatabase(`, `.deleteMany(`, `dropdb`, `prisma migrate reset`, ...)
- `remote-exec` — piping a downloaded payload into a shell/interpreter
  (`curl ... | sh`, download-and-execute / RCE)
- `perm-destroy` — recursive `chmod`/`chown`/`chgrp -R`
- `interpreter-exec` (#477) — a general-purpose interpreter (`node`, `python`, `perl`, `ruby`,
  `php`, `osascript`, `pwsh`/`powershell`, `bash`/`sh`/`zsh`, `deno`) invoked with inline-eval
  flags (`-e`/`-c`/`-r`/...) or with no script argument at all (bare-stdin execution) — an
  arbitrary read/write/exec capability invisible to redirect/verb-based parsing, since the write
  happens inside the interpreter's own code. Running a committed, on-disk script file
  (`node script.js`) stays allowed; only inline/stdin evaluation is flagged.

Destructive actions are **gateable, not denied outright**: they require an explicit approval
artifact. `requireApprovalForDestructiveAction({ action, taskId, approvedArtifacts })` (pure) and
`guardrails.evaluateDestructiveAction({ action, taskId, approvals, expectedRunId })` (wired to the
audited approval path, #133) resolve a per-task `destructive-action:<taskId>` artifact through the
same `trustedApprovedArtifacts` audit used by the required-approval and guardrail-override gates —
one audit, no drift; a foreign-run or malformed record cannot unblock. `guardrails.isDestructiveTaskOrAction(task, action)`
combines the declared task flag with content classification.

This is distinct from and does not replace the validator sandbox (`validator-sandbox.js`, #119),
which stays the stricter authority for validator/reviewer/security contexts (any mutation denied,
no approval path). Refactoring the sandbox to consume this classifier is a deliberate follow-up —
the two encode different policies (gate-with-approval vs deny-outright).

## Environment & secrets writes (Business Hub, #238)

`POST /api/env-write` on the Business Hub sets keys in the project's `.env` behind the SAME
destructive-action gate every builder faces — the hub dogfoods its own governance.

**Two-step approval contract** (the plaintext value is never persisted pre-approval):

1. **Request.** The hub validates the key (`^[A-Z][A-Z0-9_]*$`), the value (string, ≤ 4 KiB), and
   that `.env` is gitignored (`git check-ignore`; a non-ignored `.env` refuses with
   `409 gitignore_required` — a committed `.env` leaks every secret in it). The write classifies as
   `secret-write` through the central classifier (`classifyDestructiveAction`, #131). With no
   trusted approval on file, a PENDING entry for the artifact
   `destructive-action:env-write:<KEY>` lands in the `.rstack/approvals.jsonl` queue (it renders on
   the Approvals page like any other gate) and the request returns `409 approval_required`.
   **The value is discarded** — it exists only in the requester's browser tab.
2. **Approve.** A manager approves the artifact through the normal path (`/api/approve` or
   `sdlc_approve`). Everything that governs approvals applies for free: `policy.json` **`managers`**
   (`assertManagerAllowed` — when set, only listed identities may approve) and
   **`enforce_in_express`** are LIVE gates, and the dashboard path stamps token-verified actor
   evidence (#133).
3. **Write.** Re-submitting the request finds the approval via the audited path
   (`trustedApprovedArtifacts`, queue casing — per-record validation plus the replay/ordering
   history checks; forged, malformed, rejected or replayed records are treated as absent), then
   **consumes it atomically in-lock** (`consumeApprovedQueueArtifact` flips the record to
   `consumed`) *before* the `.env` write. One-shot: a second write to the same key needs a fresh
   approval. The consume-then-write order is crash-safe — a crash after consumption loses the
   approval, never the gate. The write itself is locked + atomic and preserves untouched `.env`
   lines verbatim (`src/core/harness/env-file.js`).

**What is persisted:** `.rstack/env-writes-audit.jsonl` (append-only: ts, key, actor, outcome,
value LENGTH) and an `env_key_written` event (`{ key, actor, masked_value_length }`) on the latest
run's `events.jsonl` when a run exists (skipped silently otherwise — run event streams are
run-scoped; the audit file is the run-independent record).

**What is never persisted or served:** the plaintext value — not in state snapshots (the
Environment page lists key names + lengths only), not in events, not in audit files, not in logs,
not in any GET or POST response body. The only place the value lands is `.env` itself, after
approval.

`POST /api/decide` (resolve/waive Decision Queue items from the hub) sits behind the same trust
boundary as `/api/approve`: approval token required (both routes are DISABLED with 403 when no
`RSTACK_APPROVAL_TOKEN`/`_FILE` is configured — fail closed), CSRF origin check, JSON content type,
64 KB body cap, per-IP rate limit. It routes to the harness `decide()` — never a second
implementation.

## Terminal completion, child-contract invalidation, and rollup freshness (#484)

Two of the audit's confirmed findings closed; a third investigated and correctly NOT
"fixed" once direct inspection showed the obvious reading would regress an existing, deliberately
tested contract (see below).

**Terminal completion was never actually persisted.** Direct inspection (before writing any fix)
found `completed_at` was written NOWHERE in the codebase — only ever read (by
`deriveRunStatus`/`statusFromEntry` in `src/observability/dashboard/state/{runs,rollup-index}.js`,
which key their `"done"` classification on it, not on `manifest.status`). The only writer of
`status: "DONE"` was `sdlc_status`'s handler, fired opportunistically — a run that finished and was
never polled by `sdlc_status` afterward stayed `IN_PROGRESS` forever from the dashboard's point of
view, eventually classified `"stalled"` by the generic staleness path. `markRunCompleted()` in
`src/integrations/pi/rstack-sdlc.ts` now stamps `status: "DONE"` and `completed_at` atomically
(one read-modify-write under the manifest's own lock, the #288 pattern) at the ACTUAL moment of
completion — the end of `sdlc_validate`'s success path, when the last task PASSes and (for
non-express runs) the release-gate approvals are satisfied. It is idempotent: a run that already
carries `completed_at` is left untouched, so `sdlc_status`'s original check (now routed through the
same `isRunEligibleForCompletion`/`markRunCompleted` pair) remains safe as a defensive catch-up for
runs that finished under an older build, without ever moving an already-terminal run's timestamp.

**Child-contract invalidation.** `runSignature()` in
`src/observability/dashboard/state/rollup-index.js` covered `manifest.json`/`events.jsonl`/
`tasks.json`/`approvals.json`/`evidence.jsonl` + the run directory's own mtime — never
`tasks/<taskId>/{builder.json,validation.json,prompt.md}`. A retry overwrites `builder.json` IN
PLACE (same filename, same directory entry), which does not bump the parent directory's own mtime
on POSIX filesystems, so a cached "unchanged" index entry could keep serving a stale
builder/validation verdict for a privately-retried task. `childContractsSignature()` now stats all
three per-task files for every task named in `tasks.json` and folds them into the signature.

**Investigated, deliberately NOT changed: the rollup-index "bypass".** The audit's "the rollup
index can preattach a pipeline rollup and bypass that fallback" was read, at first, as "the general
index-served rollup should ALSO rebuild on `pipelineStateEventsBehind`, matching
`attachPipelineRollups`' repair branch." Implementing exactly that regressed
`tests/dashboard-command-pages.test.js`'s "the next-action rollup flags itself stale when live
events outrun the saved pipeline-state.json" — a pre-existing, deliberate test pinning the OPPOSITE
contract: the general dashboard rollup (recomputed on every hot run, every ~3s poll) must show the
honest stale/`events_behind` signal rather than silently pay a pipeline-state rebuild on every poll
for every active run — a cost this call site cannot absorb the way a single scoped read can. The
attempted change was caught by this PR's own verification pass and reverted before merge (a live
demonstration of "run the tests before trusting a plausible-sounding fix," not a shortcut taken).
The audit's underlying observation likely still stands — `attachPipelineRollups`' own rebuild
branch (`src/observability/dashboard/state/index.js`) is very probably unreachable in the current
call graph, since `computeRunPipelineRollup` (rollup-index.js) runs first for every run reaching
`buildFullState` and always assigns `run.pipelineRollup` (even to `null`), starving
`attachPipelineRollups`' `=== undefined` guard — but closing THAT (removing the dead branch, or
giving it a genuinely distinct scoped caller) is a design decision for whoever owns the observer
architecture, not a call to make unilaterally inside this PR's scope. Left alone; the reasoning is
recorded in `computeRunPipelineRollup`'s own doc comment.

**Honest scope (deliberately deferred, matching the #481/#480/#482/#483 precedent)** — the issue's
full "Required design" is a much larger subsystem than the above:

- **No `state_generation` / monotonic run-level counter / outbox-event-cursor.** Direct inspection
  (grepped the whole repo before scoping) found none exists anywhere — the #481 attempt-ledger's
  per-attempt CAS `version` and unused `outbox` array are the only adjacent primitives, and neither
  is wired to run-level manifest transitions or the dashboard. Building the issue's "every committed
  harness transition atomically increments a monotonic `state_generation` and emits an outbox
  event, and the dashboard signature depends on that generation/cursor instead of guessing which
  child files changed" is a genuinely new subsystem (a different invalidation model from the
  file-signature approach `runSignature` uses today), not a slice of this PR.
- **No multi-instance WebSocket event delivery / reconnect cursor reconciliation.** The dashboard
  broadcasts a full-state snapshot every 3s and ad hoc after several REST mutations
  (`src/observability/dashboard/server.js`) — there is no per-run/per-transition event message type,
  no cross-instance publish, and no cursor to reconcile on reconnect. This is the issue's "Reactive
  delivery" section in full; unstarted.
- **No degraded/error surface for partial/corrupt control-plane state** beyond the existing #82
  data-integrity collector (run-level "data damaged" badge) — the issue asks for this at the
  generation/transition layer specifically, which doesn't exist yet per the point above.
- **No harness-side push on a background-worker/direct-control-plane write.** Every rollup
  recompute in the dashboard today is dashboard-initiated (poll or REST/WS request) — there is no
  hook from a completed pipeline run, `sdlc_validate` verdict, or checkpoint restore INTO a running
  dashboard process. Closing this is bundled with the `state_generation`/outbox work above, since a
  push needs something to push.
- **No integration tests for the two-dashboard-instance / dropped-WebSocket-event / 15-stage-full-
  run scenarios** the issue's "Required integration tests" section lists — those exercise the
  subsystem above, which does not exist yet.

## Notification typed sender contract + incident coalescing (#485)

Three confirmed, bounded fixes closed; the issue's full "transactional notification outbox /
coalescing policy / retry-backoff-dead-letter" design is a separate, much larger subsystem (see
the honest scope note below) — comparable in size to the #481 attempt-ledger build.

**Typed sender contract.** `notifyAll()` (`src/notifications/router.js`) only ever treated a
THROWN sender error as `ok:false` — anything a sender resolved with, it stamped `ok:true`
unconditionally. `sendEmail()` (`src/notifications/channels/email.js`) has a deliberate,
documented "never throws" contract (a webhook failure must never fail a run), but its failure
paths returned bare STATUS STRINGS (`'email: unconfigured (...)'`, `'email: no recipients
resolved'`, etc.) — every one of which `notifyAll` recorded as a successful delivery, the exact
false-positive the audit named. `sendEmail` now returns `{ ok: boolean, message: string }`
instead, and `notifyAll` interprets that shape directly when present (a plain string or `undefined`
from any other sender is still treated as legacy-success, unchanged — every other channel already
correctly throws on failure). A second instance of the same bug class, one level lower: the
Telegram Bot API can return HTTP 200 with a body-level failure (`{"ok":false,"error_code":...}` —
bad `chat_id`, bot blocked by the user); `postJson` only checks the HTTP status code, so this
resolved as success too. `sendTelegram` now parses the body and throws on a body-level failure
(`assertTelegramBodySucceeded`, exported and directly unit-tested — `sendTelegram` hardcodes the
real `api.telegram.org` host, so the decision logic is tested in isolation rather than through a
fake server).

**Incident coalescing for the validate path.** Confirmed against the real code, matching the
audit's own math ("15 stages × 3 attempts × 2 messages ≈ 90... exhaustion can push it above 100"):
`sdlc_validate` unconditionally fired a "stage status" message AND a separate "task report"
message on EVERY call — PASS or FAIL — with zero memory of prior attempts on the same task, plus a
THIRD "approval required" message on exhaustion. Two bounded fixes:
  1. Exactly ONE notification per validate call, not two — the task report
     (`formatSlackTaskReportMessage`) is a strict superset of the stage message's content, so the
     stage message is now only the fallback when `buildRunReport` finds no trace for the task. The
     report gained an optional `banner` parameter (`src/notifications/index.js`) so the
     incident-coalescing framing (open/escalate/resolve) survives even when the rich trace is used.
  2. `src/notifications/incidents.js` — one incident per task's current unbroken failure streak,
     persisted at `.rstack/runs/<runId>/notification-incidents.json`. A FAIL on an already-open
     incident is suppressed from external channels (a pinned `notification_coalesced` event still
     records it — nothing is silently dropped from the ledger); exhaustion escalates the SAME
     incident exactly once; a later PASS resolves and clears it with exactly one recovery
     notification. Every dispatch attempt (sent or would-have-been-sent) is recorded in a pinned
     `notification_dispatched` event with per-channel `{channel, ok}` — directly serving the
     acceptance criterion "per-channel delivery status is accurate and queryable" without requiring
     real network access to test the wiring (channel delivery itself is tested separately in
     `tests/notifications-channels.test.js` / `tests/email-approvals-353.test.js`).
  3. The pre-existing, separate `approval_required` ping fired by the exhaustion branch is
     UNCHANGED and NOT merged into the coalescing engine — it is a distinct call-to-action for a
     human to approve an override, not a status report; merging it would blur that purpose. Net
     effect on the audit's own worked example (3 failed attempts, budget raised to 3 for the test):
     1 dispatch (open) + 0 (suppressed) + 1 dispatch (escalate) = 2 coalesced dispatches, plus the
     separate unchanged approval ping — down from the old 2×3 + 1 = 7.

**Malformed routing configuration is no longer silent.** `router.js`'s `fileConfig()` and
`recipients.js`'s `readJson()` used to swallow a syntactically-invalid or wrong-shape
`notifications.json`/`policy.json` to `{}`/`null` with ZERO signal — not even a log line — so
routing silently degraded to nothing. Both now `console.error` at the point of the actual runtime
degrade (non-blocking, matching this layer's fire-and-forget rule), pointing at `rstack-agents
doctor` for the full diagnosis. `validateNotificationsConfig` (the existing #151 semantic check,
called from `doctor`/dashboard) is unchanged — this closes the gap where nobody proactively ran
doctor and the failure was otherwise invisible.

**Honest scope (deliberately deferred, matching the #481/#480/#482/#483/#484 precedent)** — the
issue's full "Required design" is a genuinely separate, much larger subsystem than the above:

- **No transactional notification outbox.** The issue asks for notification intents created from
  the SAME committed harness event/outbox the #481 attempt-ledger uses for state transitions
  (deterministic idempotency key, run/stage/task/attempt+event IDs, severity, channel targets,
  delivery attempts/next-retry, terminal delivered/dead-letter state). `src/notifications/
  incidents.js` is a narrower, purpose-built state machine for ONE thing (suppressing redundant
  validate-path notifications) — it does not generalize to every notification call site
  (`sdlc_start`, `sdlc_approve`, the guardrail/approval-gate-blocked paths at `sdlc_build_next`
  still notify unconditionally on every call, uncoalesced), and it is not wired to the #481 ledger's
  own outbox (confirmed still a documented no-op at the exact drain point in `sdlc_validate` —
  see the comment at `rstack-sdlc.ts`'s `drainOutbox` call).
- **No durable retry/backoff for failed deliveries.** A failed `notifyAll` call today is a single
  attempt; the result is logged and discarded. No exponential backoff with jitter, no maximum
  attempt count, no requeue.
- **No dead-letter queue or its visibility.** A permanently-failed notification has no distinct
  terminal state from a transient one — both are just a logged `ok:false`.
- **No global/per-channel rate bounds, quiet-hour policy, or digest rules.**
- **No Business Hub incident/delivery-status UI.** The Environment page still only lists configured
  channel NAMES (`src/observability/dashboard/ui/pages/environment.js`) — no delivery status,
  incident feed, or dead-letter indicator. The new `notification_dispatched`/`notification_coalesced`
  events are queryable from `events.jsonl` today, but nothing renders them yet.
- **No required-tests items requiring the above**: "restart the notification worker during
  backoff," "exhaust retries and inspect dead-letter visibility," "exercise rate-limit and
  quiet-hour policy" all exercise subsystems that don't exist yet.

## Durable worker scheduler — `pipeline watch` (#486, epic #476 Wave 2's final child)

The issue asks for a full durable scheduler: a persistent work queue, lease-based claims with
fencing tokens/heartbeats, distinct builder/validator/Scientist worker identities, a dependency-
graph-authorized parallelism engine, per-run budgets across time/tokens/cost/attempts/CPU/memory/
external-calls, circuit breakers, maintenance windows, an `AUTO_DECIDABLE`/`HUMAN_REQUIRED`/`DENY`
policy decision point, run pause/resume/cancel with audited principals, and tenant/repository
isolation. Direct inspection before scoping confirmed almost none of this exists — this codebase
has real leases/CAS/fencing (#481's attempt-ledger), real attempt-count and cost budgets, and real
config-declared (not dependency-graph-derived) parallel groups, but **the single biggest gap was
structural, not a missing policy dimension: there was no standing worker process at all.** Every
`pipeline run`/`pipeline loop` invocation is a bounded, one-shot loop that returns control to the
shell — a human or agent had to manually re-invoke the CLI after every builder execution, every
approval, every retry. `rstack-agents pipeline watch` (`src/commands/pipeline-watch.js`) closes
that one gap, reusing 100% of the existing claim/validate/retry/reclaim/parallel machinery
underneath (`runPipeline`, unchanged).

**HONEST ARCHITECTURAL BOUNDARY — read before assuming this closes the full issue.** This harness
is deliberately model-free (`pipeline-run.js`'s own header comment): `sdlc_build_next` prepares a
builder packet but never itself invokes an LLM to do the coding work. `pipeline watch` is therefore
a **scheduling/bookkeeping worker, not a builder-execution worker** — it cannot make a
`missing_contract` wait condition (a packet prepared, awaiting an agent) go away by itself. What it
removes is the human/agent's burden of re-running the CLI after every step: claim dispatch,
immediate re-validation the moment a builder contract appears, retry-policy re-entry, orphan
reclaim, parallel-group dispatch, and — the acceptance criterion this most directly serves — safe
unattended WAITING at a genuine gate (pending approval, `ask_user`, retry-budget-exhausted, or
awaiting builder execution), resuming automatically the moment the condition clears, without a
fresh CLI invocation. True hands-off execution of the coding step itself would require this process
to also spawn and supervise a real coding-agent session — a much larger, separate, security-
sensitive capability, deliberately not attempted here (see the deferred list below).

**What shipped:**
- `decideNextTick()` — the pure state-transition function (no I/O, no clock reads, fully
  reproducible in tests): maps `runPipeline`'s `stopped_on` vocabulary onto scheduler statuses.
  `pending_approval`/`ask_user`/`blocked_retry_policy` → `paused_human_gate`; `missing_contract` →
  `awaiting_builder_execution` — both are legitimate, EXPECTED waits and are never counted toward
  the circuit breaker (the issue's own language: "pause safely... and notify/coalesce rather than
  weaken authentication"). `no_actionable_work` (genuinely ambiguous) and repeated tick errors DO
  count, tripping a simple circuit breaker (`stopped_circuit_breaker`) after N consecutive
  occurrences (`--stall-limit`/`--error-limit`, defaults 10/5) — a bounded, honest circuit breaker,
  not the issue's full policy engine.
- A wall-clock time budget (`--max-hours`, default 24) checked every tick before anything else, so
  the loop can never overshoot even if a tick itself made progress.
- A persisted heartbeat (`.rstack/runs/<runId>/scheduler.json`: pid, status, reason, ticks,
  consecutive counters, last-tick timestamp) written atomically every tick — the queryable
  "scheduler health/readiness" state the acceptance criteria ask for, at the data layer.
- Duplicate-execution prevention: `assertNoLiveWatcher()` refuses to start a second watcher over
  the same run while a live one's heartbeat (checked via `process.kill(pid, 0)` liveness, not just
  file presence) is non-terminal — one level above the existing file-locked claim gate, which
  already prevents two claims from colliding but not two SCHEDULERS from redundantly driving the
  same run.
- Graceful `SIGINT`/`SIGTERM` handling: a signal sets a flag checked at the START of the next
  decision (never mid-tick), finishing the current tick's already-atomic writes before exiting 0
  with `stopped_by_signal` — live-verified (not just unit-tested) with real `kill -INT`/`kill -TERM`
  against a real spawned process during this PR's own manual verification pass.
- A single notification per STATUS TRANSITION (never per tick, never repeated for an unchanged
  status) via the existing `notifyAll` — applying the #485 coalescing lesson one level up: a status
  CHANGE is what deserves a human's attention, not every poll.
- A real Commander.js footgun caught and fixed during this PR's own build: `--no-progress-limit`
  collides with Commander's `--no-` boolean-negation prefix (it would have silently parsed as a
  boolean flag, never accepting the intended numeric value) — renamed to `--stall-limit` before
  shipping, caught by manually running `--help` and inspecting the parsed option, not by a test.

**Deliberately deferred (a genuinely separate, much larger build — this issue's full "Required
architecture" is comparable in scope to the entire #481 attempt-ledger effort, or larger)**:
- **No worker identities beyond the existing lease-owner string.** No distinct builder/validator/
  Scientist role registry, no per-worker capability declarations.
- **No `AUTO_DECIDABLE`/`HUMAN_REQUIRED`/`DENY` policy classification anywhere.** The approval model
  remains uniform (every gated artifact needs an audited human-authored record) — confirmed by
  direct grep before scoping, zero hits for this vocabulary anywhere in the codebase. `pipeline
  watch` treats every `stopped_on` the harness ALREADY classifies as human-gated (`pending_approval`,
  `ask_user`, `blocked_retry_policy`) as a wait condition, but it does not introduce a new formal
  policy-decision-point abstraction.
- **No dependency-graph-derived parallelism.** Parallel dispatch still goes through the existing
  config-declared `parallel_groups` (#208) unchanged — `pipeline watch` ticks drive whatever
  `planNextAction` would already decide, including `claim_group`, but does not compute a new DAG.
- **No CPU/memory/external-call budgets** — only wall-clock time (this PR) and the pre-existing
  attempt-count/cost budgets are enforced. No maintenance windows. No tenant/repository isolation
  (a single watcher instance is scoped to one run in one project root; running two watchers over
  two DIFFERENT projects concurrently is untested, though nothing in the design should conflict —
  each writes only inside its own run directory).
- **No true fencing-token reassignment for the SCHEDULER process itself.** The duplicate-prevention
  check is a liveness probe at startup, not a lease the scheduler itself holds and renews the way
  #481's attempt-ledger leases work — a killed-and-immediately-restarted watcher within the same
  liveness-check window is not specifically guarded against (though the underlying claim gate's own
  locking still prevents any actual double-claim).
- **No Business Hub UI.** The heartbeat file is real, atomic, and queryable today — nothing renders
  it yet. Matches the #484/#485 precedent of shipping the queryable substrate before the dashboard
  panel.
- **No spawning/supervision of a real coding-agent process** — the fundamental model-free-
  architecture boundary described above. This is the largest single piece of "24-hour unattended"
  that remains structurally out of reach without a separate, much bigger capability.
- **Required resilience tests not attempted**: "partition the state store/event bus," "run two
  repositories concurrently and verify isolation," "replay duplicate queue messages" (there is no
  message queue to replay against), and genuine multi-hour/multi-day soak tests (this PR's tests use
  an injected clock to simulate elapsed time instantly — a real 24-hour unattended run has not been
  executed).

### #487 — bridge/delegate subprocess timeouts, across every harness (not just Tau)

The issue named only "Tau + JS bridge," but the underlying bug class — a bridge/guard/observe
subprocess with no timeout, and even where a timeout existed, killing only the tracked pid — was
verified and fixed across **every** harness adapter that shells out to a subprocess: Tau, Operator,
Hermes (all three Python adapters), and the JS-side `bridgeInvoker` (the engine behind the #486
durable worker) + Pi's `runDelegateAgent`. Claude Code has no subprocess adapter code (guard-hook
only) and was not in scope.

**The core discovery, live-reproduced before being fixed**: a bare `proc.kill()` (asyncio) /
`subprocess.run(timeout=...)` (its built-in TimeoutExpired handling) / `child.kill()` (Node) only
kills the ONE tracked process. A real `npx`/wrapper-script invocation commonly forks a grandchild
that inherits the parent's stdout/stderr pipe file descriptors. Killing only the tracked pid leaves
that grandchild running and holding the pipe open — on the Python asyncio side this made
`proc.wait()`/`communicate()` block for the FULL original hang duration regardless of the configured
timeout having already fired (reproduced: a 1s configured timeout took 100s wall-clock against a
real forking wrapper, before the fix). Fixed uniformly: every spawn now owns its own process group
(`start_new_session=True` in Python, `detached: true` in Node — POSIX-only; Windows keeps the
pre-existing plain single-pid kill, not a regression since Windows process-tree killing was never
handled here before), and the timeout/abort path kills the WHOLE group (`os.killpg`/`process.kill(-pid, ...)`)
before reaping.

**Notable finding**: Operator's own previously-shipped (#391) `_communicate_or_kill` helper — the
adapter believed going in to be the "correct" reference pattern — had this exact same bug (never
passed `start_new_session=True`), meaning that earlier fix was itself subtly incomplete. Also,
an earlier research pass had concluded Hermes was "already safe" because it uses
`subprocess.run(timeout=...)` everywhere; live-testing proved that conclusion wrong —
`subprocess.run`'s built-in timeout handling has the identical single-pid-kill limitation, and a
real reproduction (a wrapper forking `sleep 100 &` before hanging itself) left a genuine orphan
even after `TimeoutExpired` fired correctly.

**Verification method**: every Python fix was live-tested against the REAL installed package
(`tau-coding-agent`, `operator-use`, `hermes-agent`, all via pip into throwaway venvs) with a real
forking hung wrapper standing in for a stalled `npx`, not just unit-tested with mocks — matching
this repo's established #389–#391 precedent. Every fix (Tau's 5 call sites, Operator's 3, Hermes'
3, the JS `bridgeInvoker`) was also mutation-tested: reverting the process-group kill was confirmed
to reproduce a real orphaned process via `pgrep`, proving the fix (and its test, for the JS side)
is load-bearing, not a no-op.

**Fixed, with env-tunable timeouts** (`RSTACK_BRIDGE_TIMEOUT_MS` default 60s, `RSTACK_GUARD_TIMEOUT_MS`
default 15–30s depending on adapter, `RSTACK_DELEGATE_TIMEOUT_MS` default 30 minutes for Pi's
delegate spawn — generous because delegate work is a legitimately long-running real coding session,
unlike the short bridge-call timeouts): Tau (`_run_bridge`, `_run_gate`, `_run_guard`,
`_emit_observation`, `_fetch_context`), Operator (`_run_bridge`, `_run_guard`, `_after_tool_call`),
Hermes (`_run_bridge`, `_run_guard`, `_observe_hook`), JS `bridgeInvoker` (adds an optional
`AbortSignal` third parameter + a `child.on('error', ...)` handler — previously entirely absent, so
a spawn failure like ENOENT could leave the promise pending forever), and Pi's `runDelegateAgent`
(a defense-in-depth backstop independent of whether any `AbortSignal` is ever supplied — confirmed
via `bin/rstack-bridge.ts` that a bridge-invoked `sdlc_delegate` call from Tau/Hermes/Operator gets
`signal === undefined` today, since the bridge protocol does not plumb an AbortSignal through).

**Deliberately deferred**: true cross-process `AbortSignal` propagation through the entire bridge
protocol (would let an external caller cancel a long-running delegate mid-flight cooperatively,
rather than relying only on the timeout backstop) — a bigger, separate change to the bridge
invocation contract itself, out of scope for a subprocess-hygiene fix. Windows process-tree killing
beyond the pre-existing single-pid fallback (no Windows CI runner in this repo to verify against).
No automated CI-integrated Python test suite exists for the adapters (none existed before this fix
either) — Python-side verification is live/manual, matching this repo's established precedent;
JS-side fixes have real `node --test` coverage (`tests/bridge-invoker-timeout-487.test.js`).
