Repository navigation
chore: promote staging to staging-promote/41c73878-24785020894 (2026-04-22 15:56 UTC) - #2860
Conversation
* feat(safety): projection-exempt lint for gateway event sources Phase 1 of the gateway state-convergence epic (#2792): add check #9 to `scripts/pre-commit-safety.sh` that flags newly-added `sse.broadcast(` / `sse.broadcast_for_user(` calls without a `// projection-exempt: <reason>` annotation on the same line. The invariant is documented in the new `.claude/rules/gateway-events.md`: - Every `AppEvent` must project from a typed source log (engine `EventKind`, sandbox `JobEvent`, or a channel-lifecycle log). - A short transport-only allowlist (`Heartbeat`, `StreamChunk`) covers the ephemeral variants with no state backing them. - Direct emits are the root cause of the state-drift class — UI stream and replayable source end up with different stories. Four recent incidents (#2654, #2534, #2731, #2079) share this shape. The lint is diff-based, so pre-existing unannotated call sites aren't broken. Baseline annotation of the ~20 existing emit sites is the next PR under Phase 1 — this one establishes the gate. Suppressions require a named category (`bridge dispatcher`, `channel-lifecycle`, `sandbox JobEvent`, `transport-only, heartbeat`, or `migrate in #NNNN`). An unnamed `legacy` reason is rejected by review, not by the lint itself. Tested locally: - Fires on unannotated `sse.broadcast(...)` in a new file. - Suppressed by `// projection-exempt: transport-only, heartbeat`. - Does not match `Channel::broadcast` (different trait). - Does not match calls inside `#[cfg(test)] mod tests` blocks (via the shared `strip_test_mod_lines` filter). Refs: #2792, #2654 * refactor(safety): address review feedback on projection-exempt check Four review comments from Copilot and Gemini on #2840: 1. **Match rustfmt's method-chain wrapping.** The original regex only caught same-line `sse.broadcast(...)`. Long calls like `state\n .sse\n .broadcast_for_user(...)` — produced by rustfmt and already in-tree at `src/channels/web/features/extensions/mod.rs:645` — would bypass the check. New matcher adds a dangling-method alternation that catches `.broadcast_for_user(` at line start. Only the `_for_user` suffix (SseManager-unique) is matched in dangling form; bare `.broadcast(` can be `Channel::broadcast` trait, which is intentionally out of scope. 2. **Enforce the documented annotation format.** The check previously accepted any `// projection-exempt:` comment, including bare `// projection-exempt: legacy` that the rule doc explicitly forbids. Negative filter now requires `<category>, <detail>` — presence of a comma separating the category from the detail. 3. **Point at the real path in the warning.** Replace `bridge::thread_event_to_app_events` with `thread_event_to_app_events` in `src/bridge/router.rs` — the actual file location. 4. **Update suppression hint** to show the `<category>, <detail>` format rather than the generic `<reason>`. Verified against a 6-case fixture (same-line fire + suppress, dangling-chain fire + suppress, unnamed-category fire, `Channel::broadcast` silent). Refs: #2792, #2840 review * fix(safety): match header exclusion against grep -n prefixed output After `grep -nE '^\+'`, every line is prefixed with `N:`, so the `^\+\+\+` anchor for filtering diff header lines (`+++ b/file.rs`) never fires. The positive patterns already exclude header lines by shape, so today this is harmless — but the dead branch masks future defense-in-depth failures if the template is reused with a less specific positive match. Replace `^\+\+\+` with `:\+\+\+ ` in DISPATCH, CREDNAME, and PROJECTION checks so the exclusion works against the `grep -n` output shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(safety): regression for grep-n-prefixed header exclusion Covers PROJECTION / DISPATCH / CREDNAME pipelines: - diff header lines (`+++ b/path`) are filtered after `grep -n` - real broadcast/state/CredentialName lines are still flagged - `// projection-exempt: <category>, <detail>` exempts - bare `// projection-exempt: legacy` (no comma) is not exempt Locks in that `:\+\+\+ ` (matches the `grep -n` prefixed shape) behaves as intended, where the prior `^\+\+\+` anchor silently never fired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(deny): ignore RUSTSEC-2026-0104 (rustls-webpki CRL panic) Same transitive pin as 0049/0098/0099 — rustls-webpki 0.102.8 is held by libsql 0.6.0 → rustls 0.22 → hyper-rustls 0.25. The advisory explicitly notes that applications not parsing CRLs are unaffected; we do not parse CRLs. [skip-regression-check] — deny.toml-only config change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(safety): portable grep boundary + broadened broadcast_for_user match Two PROJECTION bypass paths flagged in review: 1. `\b` is a GNU-grep extension (works in grep 3.x, not portable to BSD grep on macOS dev envs) — replace with `(^|[^[:alnum:]_])sse\.` so the check fires uniformly across `grep -E` implementations. 2. `broadcast_for_user(...)` on a non-`sse` receiver (e.g. `manager.broadcast_for_user(...)`) previously slipped through. The method is defined only on `SseManager` (`src/channels/web/platform/sse.rs:144`), so matching `\.broadcast_for_user\(` on any receiver is safe and makes the enforcement match the documented rule. Regression tests extended: chained-receiver, non-`sse` receiver, bare `sse.broadcast(`, and a portable-boundary negative case (identifier ending in `sse`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(gateway-events): align matcher description with broadened check Update the enforcement section to describe the two current PROJECTION matcher shapes after the review follow-up in the preceding commit: 1. Any-receiver `.broadcast_for_user(...)` — catches the non-`sse` receiver bypass and rustfmt wraps alike. 2. `<word-boundary>sse.broadcast(...)` with a portable boundary (`(^|[^[:alnum:]_])`), which is needed because `grep -E`'s `\b` is a GNU extension and not available on BSD grep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(safety): tighten CREDNAME + projection-exempt lints, sync header Three follow-ups from the review: 1. CREDNAME portability — `\bCredentialName\b` used GNU-grep `\b`, which BSD grep does not recognise. Replace with the same `(^|[^[:alnum:]_])…([^[:alnum:]_]|$)` boundary used for PROJECTION and matches cleanly across GNU and BSD `grep -E`. 2. Empty-detail suppression bypass — `// projection-exempt: [^,]+,` accepted `// projection-exempt: foo,` (empty detail) as exempt even though `.claude/rules/gateway-events.md` requires a non-empty detail. Tighten to `[^,]+,[[:space:]]*[^[:space:]]` so a comma without a trailing token still fires the check. 3. Header suppression hint (`#24`) said `// projection-exempt: <reason>` — update to `<category>, <detail>` to match what the check actually accepts so contributors don't copy an unsupported format. Regression tests extended: `PROJECTION: empty detail after comma still flagged`, `PROJECTION: comma + whitespace-only detail still flagged`, `CREDNAME: CredentialNameExt (different type) is not flagged`. All 16 cases pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(bridge): project 7 more engine events to AppEvents Second slice of #2654 bridge-coverage under #2792 Phase 1. Builds on #2797 (StepFailed, ChildCompleted, CodeExecutionFailed) by closing the remaining cheap `EventKind` drops: | `EventKind` | `AppEvent` | |---|---| | `LeaseGranted { lease_id, capability_name }` | new `LeaseGranted` | | `LeaseRevoked { lease_id, reason }` | new `LeaseRevoked` | | `LeaseExpired { lease_id }` | new `LeaseExpired` | | `SelfImprovementStarted` | new `SelfImprovement { phase: Started, .. }` | | `SelfImprovementComplete { prompt_updated, patterns_added }` | `SelfImprovement { phase: Complete, prompt_updated, patterns_added, .. }` | | `SelfImprovementFailed { error }` | `SelfImprovement { phase: Failed, error, .. }` | | `OrchestratorRollback { from, to, reason }` | new `OrchestratorRollback` | The three engine `SelfImprovement*` variants collapse into one wire event with a `SelfImprovementPhase` discriminator — consumers need one handler, variant-specific data is conveyed via optional phase-scoped fields. Following the types.md "Wire-stable enums" pattern — the phase enum is snake_case serde, not a stringly-typed `status` field. The lease events are security-visible: capability grants, revocations, and expiries should be auditable on the UI stream. `LeaseExpired` in particular closes a "tools start failing after TTL with no visible reason" gap. Also: - Adds `impl fmt::Display for LeaseId` in the engine alongside the existing `ThreadId` / `ProjectId` impls. The bridge code stringifies `LeaseId` for the wire; missing `Display` blocked the first compile. - Cleans up a stray doc-comment misplacement from #2797 where the `thread_event_to_app_events` docstring was attached to `code_execution_category_to_wire`. Regression tests mirror the #2797 pattern — one per representative arm (lease grant for the lease family, self-improvement complete for the richest phase, orchestrator rollback). The two remaining lease variants and two remaining self-improvement phases are covered by the existing `event_type_matches_serde_type_field` drift-catch test. Approval pair (`EventKind::ApprovalRequested` / `ApprovalReceived`) is still deferred — they need to land together with the gate-manager migration in Phase 1 PR 3 to avoid duplicate-emit with the direct `GateRequired` / `GateResolved` broadcasts. Refs: #2792, #2654 * refactor(bridge): typed SelfImprovementPhase + exhaustive match Addresses two Gemini review comments on #2844. **1. `SelfImprovementPhase` as a typed internally-tagged enum.** Previously the `AppEvent::SelfImprovement` variant carried three `Option<T>` fields (`prompt_updated`, `patterns_added`, `error`), only some of which were populated per phase. Per `.claude/rules/types.md` — and the reviewer's note — this is an `Option`-that-can-lie pattern the type system should rule out. Phase-specific data now lives on the variant: ```rust enum SelfImprovementPhase { Started, Complete { prompt_updated: bool, patterns_added: usize }, Failed { error: String }, } ``` Wire shape is preserved via `#[serde(tag = "phase")]` on the enum and `#[serde(flatten)]` on the `AppEvent::SelfImprovement.phase` field — JSON still looks like a flat object: `{"type": "self_improvement", "phase": "complete", "prompt_updated": true, ...}`. **2. Exhaustive `thread_event_to_app_events` match.** Dropped the `_ => vec![]` wildcard in favour of explicit arms for every `EventKind` variant. Deferred-bridge variants get `vec![]` with a comment naming the migration plan: - `ApprovalRequested` / `ApprovalReceived` → waiting on the gate manager migration in #2792 Phase 1 PR 3 to avoid duplicate-emit with the existing direct `GateRequired` / `GateResolved` broadcasts. - `Unknown` → forward-compat catch-all in the engine enum; nothing useful to project from a variant written by a newer binary during a rolling deploy. New engine variants now fail the bridge to compile, which is exactly what the state-convergence epic (#2792) needs — no more silent drops. Refs: #2792, #2844 review * fix(bridge): sanitize OrchestratorRollback.reason before SSE projection `EventKind::OrchestratorRollback.reason` originates from `format!("execution failed: {e}")` in `crates/ironclaw_engine/src/executor/loop_engine.rs:327`, where `e: EngineError`. Variants like `Store { reason }` and `Llm { reason }` render DB connection strings, file paths, and raw upstream HTTP bodies — all of which reached every authenticated SSE consumer verbatim through the new `AppEvent::OrchestratorRollback` projection. Route the reason through a new `user_facing_rollback_reason` classifier that maps the existing `FailureCategory` taxonomy to short operator-facing messages (`"LLM provider unavailable"`, `"execution failed"`, etc.). The raw text still lives in the `debug!` log for operator triage, matching the pattern already used for `AppEvent::Error`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(deny): ignore RUSTSEC-2026-0104 (rustls-webpki CRL panic) Same transitive pin as 0049/0098/0099 — rustls-webpki 0.102.8 is held by libsql 0.6.0 → rustls 0.22 → hyper-rustls 0.25. The advisory explicitly notes that applications not parsing CRLs are unaffected; we do not parse CRLs. [skip-regression-check] — deny.toml-only config change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(bridge): cover 4 new engine→AppEvent arms; sharpen rollback test Review follow-ups on PR #2844: - Add unit tests for `LeaseRevoked`, `LeaseExpired`, `SelfImprovementStarted`, and `SelfImprovementFailed` bridge arms — only `LeaseGranted` / `SelfImprovementComplete` / `OrchestratorRollback` had coverage before, leaving four new projections untested. - Rewrite `rollback_reason_drops_engine_error_detail` to drive the sanitiser with two unrelated leaky inputs and assert identical outputs (`execution failed`). The load-bearing check is input-independence; `!contains` probes remain as sentinel sniffs for the specific leak shapes. Avoids classifier-triggering tokens (no `upstream`, no `http 5xx`) so both inputs fall through to `Unknown`. - Reword the `ApprovalRequested` / `ApprovalReceived` comment: they are temporarily suppressed pending the gate-manager migration, not permanently dropped. The bridge will eventually map them here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code reviewFound 3 issues:
The PR adds comprehensive regression tests for the new PROJECTION check in Recommendation: Verify that
The new Recommendation: Either remove the
The fix to make the diff-header filter portable ( Recommendation: Use an explicit pattern like Positive findings:
|
Auto-promotion from staging CI
Batch range:
7fb41555a9e55677d1aaea29ca567a5b369c2b05..c559a58810896093a5cf768b424906a6f94aad7bPromotion branch:
staging-promote/c559a588-24788480958Base:
staging-promote/41c73878-24785020894Triggered by: Staging CI batch at 2026-04-22 15:56 UTC
Commits in this batch (79):
onboardfails with "Failed to save settings to database", butironclawstarts successfully and applies migrations #846) (fix(setup): run migrations during onboard when DATABASE_URL preset (#846) #2309)Current commits in this promotion (2)
Current base:
staging-promote/41c73878-24785020894Current head:
staging-promote/c559a588-24788480958Current range:
origin/staging-promote/41c73878-24785020894..origin/staging-promote/c559a588-24788480958Auto-updated by staging promotion metadata workflow
Waiting for gates:
Auto-created by staging-ci workflow