Skip to content

feat(BL-P2-085): cross-project scoping atomic security (FR1~FR4)#85

Merged
bluejayA merged 44 commits into
mainfrom
feature/bl-p2-085-cross-project-scoping
May 12, 2026
Merged

feat(BL-P2-085): cross-project scoping atomic security (FR1~FR4)#85
bluejayA merged 44 commits into
mainfrom
feature/bl-p2-085-cross-project-scoping

Conversation

@bluejayA
Copy link
Copy Markdown
Owner

BL-P2-085: Cross-project Scoping — Atomic Security Fix

4-layer defense-in-depth (FR1+FR2+FR3+FR4) preventing cross-project resource leakage after admin scope switches. Single atomic PR per the security design decision: partial ship leaks FR1 (adapter) or FR2 (worker) state through the gaps.

Summary / 요약

  • 44 commits / 45 files / +6270 / −990 / 1492 tests pass / clippy clean / fmt clean
  • merge-base: f66b7de (post-BL-P2-086 main HEAD)
  • 11 Phases (1~11), 18 Steps + Step-14-precedent-refactor-cycle (3 sub-cycles) + 2 cargo-review S-class fix passes
  • 4 layered guards: FR1 adapter refilter / FR2 worker origin stamp / FR3 RBAC project scope / FR4 form-selection check
  • 4 audit guard layers: Fr1Adapter / Fr2Worker / Fr3Rbac / Fr4Form
Phase Scope Key files
1–2 Foundation: CrossProjectGuard + DispatchedAction + AppError::CrossProjectBlocked infra/cross_project_guard.rs, action.rs, error.rs
3 Audit infrastructure: CrossProjectBlockEvent + fingerprint v1 + AuditLogger integration infra/cross_project_audit.rs
4–5 RBAC: check_project_scope + RbacScopeDecision + exhaustive action_to_kind infra/rbac.rs, worker.rs
6 Envelope: ScopeProvider trait + ActionSender central stamping (Step 9) context/action_channel.rs
7 FR2 worker hook (3-cycle 11a/b/c) + Phase 7 폴리싱 (actor_ctx Arc) worker.rs, app.rs
8 Neutron adapter (Step 12 query injection + Step 13a refilter pure helper + Step 13b ctx/wiring) adapter/http/neutron.rs, neutron_audit.rs, scope_refilter.rs
Step-14-precedent DRY refactor before Nova/Cinder duplication: RefilterScope / AuditEmitter / refilter_and_audit / AdapterAuditConfig / build_audit_config / ScopedItem rename scope_refilter.rs, neutron_audit.rs, registry.rs
8/14 Nova + Cinder defense-in-depth refilter (Step 14a/b) adapter/http/nova.rs, cinder.rs
9 FR4: FormSelectedIdValidator + Glance DeleteImage pre-mutation GET module/common/scope_validator.rs, worker.rs
10 FR6 UI: cross-project toast builders ui/cross_project_toast.rs
11 Background poll FR2 architectural pin + final fmt pass worker.rs

Design decisions / 설계 결정 사항

  1. 4-layer defense-in-depth (FR1+FR2+FR3+FR4) — 각 layer fail-safe deny default. 단일 layer 우회를 가정하고 inner layer가 다시 차단. / Each layer assumes the outer layers may be bypassed and re-validates.
  2. Fingerprint v1 LOCKED"v1\|user\|active\|origin\|target\|action\|resource_id" → sha256[..6]. 새 field 추가는 v2 bump 필수 (BL-P2-094). / v2 schema bump (BL-P2-094) is required to add fields; the v1 canonical is frozen.
  3. correlation_id=0 for adapter list_* — list 호출은 worker dispatch에 묶이지 않아 epoch 부재. fingerprint가 resource_id로 dedup 보장. epoch propagation은 별 cycle. / List calls aren't tied to a worker dispatch epoch; per-row uniqueness is guaranteed by resource_id in the fingerprint.
  4. Glance FR1 비대칭 결정DeleteImage만 FR4(pre-mutation owner GET)로 보호. FetchImages list refilter는 follow-up BL-P2-091 (image visibility 의미론 차이 — public/private/shared/community). / Glance image visibility semantics make a simple owner==active refilter ambiguous; tracked separately.
  5. actor_ctx.user_id wire-startup 고정cloud 필드만 ContextChanged에서 live update. multi-cloud 환경에서 user 변경 audit attribution은 BL-P2-093 (High). / user_id is captured once at startup; multi-cloud user-switch attribution is tracked as a High-priority follow-up.
  6. Cross-resource pre-mutation FR4 누락AssociateFloatingIp/AttachVolume 등 두 resource project 비교는 BL-P2-092. OpenStack 서버측 RBAC에 위임 중. / Cross-resource project mismatch currently relies on OpenStack-side RBAC; BL-P2-092 adds a client-side guard.
  7. RefilterScope::from_parts empty-string normalization + 3-layer empty-active debug_asserts (cargo-review S-class follow-ups, commits fd60d83 + b40f3af). Some("") → unscoped (fail-safe), strict("") → panic in dev. / Empty-active inputs are normalized to unscoped silently; dev builds panic to catch caller bugs.
  8. FR4 GET 실패 silent passthroughget_image Err → 기존 delete 흐름 (api_error로 사용자에 표면). 401/403/5xx fail-closed는 follow-up. / GET failures fall through to the existing delete path so the user sees the underlying error; auth/5xx fail-closed handling is a follow-up.
  9. Background poll FR2 면제poll_server_status / poll_migration_progressevent_tx만 사용, ActionSender::send 호출 X. doc-only architectural pin. / Background pollers are read-only by construction; they emit AppEvent directly and never dispatch mutation actions.
  10. RbacState::scope_decision atomicity (Codex P1 hotfix b4b4c44) — (role × scope) 결정이 단일 RwLock read 안에서. role/scope race 차단. / The (role, scope) decision is materialized inside a single RwLock read to prevent race conditions between role and project-id snapshots.

Test plan

  • cargo test --lib -- --test-threads=11492 passed / 0 failed
  • cargo clippy --lib --bins --tests -- -D warnings — clean
  • cargo fmt --check — clean
  • 2 cargo-review passes (session-5-commits + branch-full main…HEAD): NEEDS DISCUSSION verdict resolved via Phase A immediate fixes + Phase B follow-up BL registration
  • DevStack 로컬 수동 실증 — mutation block scenarios (project switch + stale form submit) / Manual DevStack validation of mutation-block scenarios across project switches
  • Toast 표시 검증 — Phase 10 builders are unused public API today; wiring is tracked as follow-up. / Phase 10 toast builders have no production caller yet; wiring follow-up is deferred.

Follow-ups registered

ID Priority Scope
BL-P2-089 Medium Glance UpdateImage Action variant + FR4 가드
BL-P2-090 Low MockGlanceWithImage configurable mock for handle_action FR4 integration test
BL-P2-091 Medium Glance FR1 — ScopedItem for Image + with_audit
BL-P2-092 Medium Cross-resource pre-mutation FR4 (FIP/port, Volume/server)
BL-P2-093 High actor_ctx.user_id cloud-switch live sync (multi-cloud audit attribution)
BL-P2-094 Low (v2-trigger) Fingerprint v2 schema (| escape + length-prefix)

🤖 Generated with Claude Code

bluejayA and others added 30 commits April 27, 2026 11:18
…rror variant

Cross-project scoping의 정책/에러 기반층(Phase 1~2 of 11)을 도입한다.
표면 hotfix(PR#82)가 차단한 폼 단의 위험 외에도 worker mutation/RBAC project-scope/
adapter filter 누출이 남아 있어 4겹 구조적 fix를 시작한다. Phase 1은 순수 정책 모듈,
Phase 2는 사용자 노출 에러 variant를 도입.

Phase 1 — Foundation (pure, no dependencies):
- Step 1: src/infra/cross_project_guard.rs — CrossProjectGuard pure functions
  - check_origin_scope / check_form_selection
  - GuardDecision { Allow, Block { reason } }
  - CrossProjectReason (4 variants: OriginScopeMismatch, FormSelectionMismatch,
    AdapterFilterViolation, UnscopedFailSafe) + as_str() audit-stable strings
  - GuardLayer (4 variants: Fr1Adapter, Fr2Worker, Fr3Rbac, Fr4Form) + as_str()
  - 9 unit tests
- Step 2: src/action.rs — DispatchedAction envelope
  - { action: Action, origin_project_id: Option<String> }
  - stamped()/unstamped()/is_stamped() helpers
  - 2 unit tests

Phase 2 — Error variant:
- Step 3: src/error.rs — AppError::CrossProjectBlocked
  - { reason: CrossProjectReason, guard_layer: GuardLayer }
  - thiserror display: "Cross-project operation blocked: {reason} (layer: {layer})"
  - reason.as_str() / guard_layer.as_str() 안정 문자열 사용 (audit 일관성)
  - 1 unit test (origin/form 두 시나리오)

Tests: 1370 → 1382 (+12) all green, clippy -D warnings clean.

이번 commit은 INCEPTION 산출물(workspace/requirements/workflow-plan/application-design +
design-review-raw codex/gemini/synthesis), CONSTRUCTION code-plan(18 Steps × 11 Phases),
그리고 stale session artifacts 정리(bl-p2-074/build-and-test/requirements-review-raw)도 함께 포함.

다음 단계 = Phase 3 Step 4 — CrossProjectBlockEvent builder + 기존 AuditLogger 통합
(src/infra/cross_project_audit.rs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r 통합

Cross-project block 이벤트 구조화 + 기존 production AuditLogger 재사용. 신규 audit
서브시스템 도입을 회피하고 rotation/sensitive masking 인프라를 그대로 활용한다.

Phase 3 — Audit infrastructure:
- Step 4: src/infra/cross_project_audit.rs (신규)
  - CrossProjectBlockEvent (13 fields):
    timestamp / actor_user_id / actor_cloud / active_project_id /
    asserted_origin_project_id / target_project_id / action_type /
    resource_kind / resource_id / resource_name / reason / guard_layer /
    correlation_id
  - to_audit_entry() — 기존 AuditEntry 형태로 매핑.
    timestamp는 to_rfc3339() (AuditEntry는 String 필드).
    resource_id None → empty string (AuditEntry는 String, NOT Option).
    필드 rename: action_type→action, resource_kind→resource_type.
    details JSON에 fingerprint/guard_layer/correlation_id/asserted_origin/target packing.
    result = AuditResult::Failed("cross_project_block:<reason as_str>")
  - fingerprint() — v1 canonical (LOCKED schema):
    "v1|user|active|origin|target|action|resource_id" → sha256[..6] → 12 hex.
    임의 변경 시 v2로 bump하고 분석 도구 마이그레이션 필수.
    sha256 선택 이유: std DefaultHasher는 Rust 버전 간 안정성 보장 X.
  - emit(event, Option<&AuditLogger>) — best-effort.
    logger 있으면 log_entry 시도 → Err 시 tracing fallback.
    logger 없으면 tracing::warn! 직접.
    절대 panic 안 함.
  - 8 unit tests (field mapping / details JSON / Failed reason /
    fingerprint canonical 재유도 / boundary collision-free /
    None resource_id 일관성 / emit-with-logger 파일 검증 / emit-without-logger 무패닉)

Dep:
- sha2 = "0.10" 추가 (RustCrypto 표준, ~50KB, fingerprint v1 schema 안정성)

Module registration:
- src/infra/mod.rs: pub mod cross_project_audit

Plan/검증:
- Step 4 진입 전 src/infra/audit.rs 시그니처 사전 점검으로 plan 가정 4건
  (timestamp 타입 / resource_id Option 여부 / 필드 rename / sha2 dep 부재)
  RED 작성 단계에서 정확히 매핑 — RED가 잘못된 이유로 깨지는 낭비 회피
- 1382 → 1390 (+8), clippy -D warnings clean

Refs:
- Plan: devflow-docs/construction/bl-p2-085/code-plan.md (Phase 3)
- Phase 1+2 review (clean): devflow-docs/construction/bl-p2-085/review-phase1-2-codex.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review (--scope branch --base ca2ec2a) flagged that emit()'s success
branch returned immediately without invoking AuditLogger::rotate_if_needed,
diverging from the established App::record_audit pattern (src/app.rs:780-793).
Under sustained cross-project block events the audit.log would grow past
MAX_LOG_SIZE (10MB) without rotation kicking in.

Fix:
- src/infra/cross_project_audit.rs: emit() success branch now calls
  logger.rotate_if_needed() and tracing::warn! on Err (best-effort, never
  propagates).
- Doc comment references App::record_audit so future readers see the parity
  contract.

Verification:
- 1390 tests stable (5/5 runs green after fix).
- clippy -D warnings clean.
- No new tests added — rotation only triggers at MAX_LOG_SIZE; parity with
  the existing call site is the contract being enforced. Existing emit-with-
  logger test continues to cover the success path.

Refs:
- Review artifact: devflow-docs/construction/bl-p2-085/review-step4-codex.md
- Pattern source: src/app.rs:780-793 (record_audit)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…preserve project

FR3(RBAC project-scope guard)와 token 재발급 시 scope 보존 메서드를 도입한다.
역할 단(can_perform)과 scope 단(target == active project_id)을 통합 판단해서
audit/toast가 거부 사유를 명확히 구분할 수 있게 한다.

Phase 4 — RBAC 확장:
- Step 5: src/infra/rbac.rs
  - RbacScopeDecision { Allow, Deny { reason: RbacDenialReason } } 신규 enum
  - RbacDenialReason { RoleTier, ProjectScope, Both } + as_str()
    "role_tier" / "project_scope" / "both" — audit details에 안정 문자열로 기록
  - RbacGuard::check_project_scope(target, action) -> RbacScopeDecision
    role_ok = self.can_perform(action)
    scope_ok = self.project_id().is_some_and(|p| p == target)
    None scope = fail-safe ProjectScope deny (별도 variant 없이 통합 처리)
  - 7 unit tests:
    admin match → Allow / admin mismatch → ProjectScope /
    unscoped admin → ProjectScope (fail-safe) /
    reader Create match → RoleTier / reader Create mismatch → Both /
    member ForceDelete match → RoleTier / reader Read match → Allow

- Step 6: src/infra/rbac.rs
  - RbacGuard::update_roles_preserve_project(roles)
    self.state.write() 잡고 roles + effective_role 갱신,
    capabilities.clear() (update_roles와 parity — caller가 update_capabilities
    로 재공급하는 가정), project_id 보존.
    Use case: Keystone token 재발급 시 같은 project scope 유지.
  - 4 unit tests:
    project_id 보존 / roles+effective 갱신 / 기존 update_roles는 여전히
    project 갱신(회귀방지) / capabilities clear parity

검증:
- 1390 → 1401 (+11), clippy -D warnings clean
- 사전 audit (rbac.rs 5분): RwLock 패턴 / can_perform 매트릭스 / TokenRole role(name)
  test helper — plan 가정 모두 정확. RED 작성 단계에서 정확 매핑.

Refs:
- Plan: devflow-docs/construction/bl-p2-085/code-plan.md (Phase 4)
- 사용처는 Phase 6 worker hook (Step 11) + Phase 9 form validation (Step 16)에서 wire-up

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…is_mutation

Action enum의 새 variant가 추가될 때 RBAC 분류를 silent miss할 수 있던 fallthrough
match (`_ => None`)를 제거하고, 모든 non-mutation variant를 명시한다. 컴파일러가
exhaustive match를 강제하므로 향후 Action 추가 시 컴파일 시점에 분류 결정을 강제.

Phase 5 — Action 분류 exhaustive:
- src/worker.rs::action_to_kind
  - `_ => None` fallthrough 제거.
  - 28개 None variant 명시 (4 카테고리):
    * Read-only: 18 Fetch* variants (FetchServers, FetchFlavors, FetchAggregates,
      FetchComputeServices, FetchHypervisors, FetchNetworks, FetchSecurityGroups,
      FetchFloatingIps, FetchSubnets, FetchAgents, FetchVolumes, FetchSnapshots,
      FetchImages, FetchProjects, FetchUsers, FetchUsage, FetchMigrationProgress,
      FetchPorts)
    * UI/Nav: Navigate, Back, FocusSidebar, EnterFormMode, ExitFormMode,
      SelectResource, NavigateToResource, ShowToast
    * System: RefreshAll, Quit, ToggleAllTenants
    * Context switch (orchestration, not RBAC): SwitchContext, SwitchBack
  - visibility를 fn → pub(crate) fn 으로 승격 (Phase 6 ActionSender가 사용 예정).
  - doc comment 추가 — exhaustive 강제 의도와 BL-P2-085 Step 7 출처 명시.

- src/worker.rs::action_is_mutation (신규)
  - pub(crate) wrapper: action_to_kind(action).is_some()
  - 사용처: ActionSender (Phase 6 Step 9)가 mutation일 때만 origin_project_id 스탬핑.
  - 현재는 caller 부재로 #[allow(dead_code)] — 의도된 임시 상태, Step 9에서 wire.

5 unit tests (worker::tests):
- test_action_to_kind_fetch_and_nav_variants_return_none — 28 variants 일괄
- test_action_to_kind_all_mutations_have_kind — 38 mutation variants 일괄
- test_action_to_kind_rbac_mapping_lockstep — 11 (mutation × ActionKind) 명시 매핑
  (Create/Delete/ForceDelete/Resize/Migrate/Evacuate/EnableDisable/ManageQuota/
  Attach/Detach 모든 ActionKind 커버)
- test_action_is_mutation_helper_parity — 래퍼 일관성
- test_action_to_kind_switch_context_returns_none — orchestration 분리 명시

검증:
- 1401 → 1406 (+5), clippy -D warnings clean.
- 사전 audit (worker.rs/action.rs/port::types 5분): param shape mismatch 13건 RED 단계
  에서 즉시 수정. 사전 audit이 RED 신호를 흐리는 것을 방지하는 효과 재확인.

다음 단계 (BL-P2-085 Phase 6 — 가장 침습적):
- Step 8: ScopeProvider trait + mock
- Step 9: ActionSender 타입 교체 (UnboundedSender<Action> → UnboundedSender<VersionedEvent<DispatchedAction>>)
- Step 10: app.rs + 테스트 헬퍼 일괄 갱신

Refs:
- Plan: devflow-docs/construction/bl-p2-085/code-plan.md (Phase 5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…6 진입 전 일시 정지)

audit.md에 다음 메타데이터 append:
- session-end: phase=CONSTRUCTION, last=Step-7, next=Phase-6-Step-8, tests=1406
- cross-session-handoff: resume instructions (worktree cd / HEAD verify / memory read /
  optional codex review / Phase 6 진입 시 사용자 확인 필요)
- flake-tracking: keystone_project_directory::max_pages_cap_trips_error
  (BL-P2-080 영역, ~1/8 runs, BL-P2-086 신규 등록 권장)

소스 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`RbacGuard::check_project_scope` previously called `can_perform()` and
`project_id()` separately, acquiring the RwLock read guard twice. A
concurrent `update_roles` / `update_roles_preserve_project` (token
refresh, context switch) could interleave between the two reads and
yield `(role_ok, scope_ok) = (true, true)` for a state where neither
consistent snapshot would have allowed the action — an authorization
bypass surface.

Fix: introduce private `RbacState::scope_decision(target, action)` that
operates on `&self` field reads. `check_project_scope` now acquires the
read guard once and delegates, so role and project_id come from the
same snapshot. Poisoned lock falls back to `Deny { Both }` (fail-safe).

- Decision logic: unchanged (single-threaded behavior identical, all 7
  existing `test_check_project_scope_*` pass).
- New test `test_rbac_state_scope_decision_atomic_snapshot` exercises
  `RbacState::scope_decision` directly — locks in the snapshot-based
  contract so future refactors cannot regress.
- 1406 → 1407 tests passing. cargo clippy -D warnings clean.
- Schema-stable: no audit event format change, no public API change.

Follow-up to commit 626cd64 (Phase 4 Step 5+6). Reviewed by Codex
GPT-5 — 2026-04-28-bl-p2-085-phase4-5-rbac-action-feat-bl-p2-085-codex.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit log additions for the 2026-04-28 resume session:
- staleness check skipped (upstream unset on worktree branch)
- devflow-state.md re-synced (stale Phase 1 entry → Phase 5 done)
- /codex:review --scope branch --base 53d7292 run (1 P1: rbac.rs race)
- P1 hotfix applied in commit b4b4c44 (1407 tests, clippy clean)

No code change — devflow bookkeeping only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the read-only scope abstraction that ActionSender (Step 9)
will use to stamp DispatchedAction.origin_project_id at send time.

- `pub trait ScopeProvider: Send + Sync { fn current_project_id(&self) -> Option<String>; }`
  Lives next to ActionSender in `src/context/action_channel.rs`. Designed
  for trait-object storage (`Arc<dyn ScopeProvider>`) so it can be cloned
  across worker tasks while remaining concurrent-safe.

- `impl ScopeProvider for Arc<RbacGuard>` — the production implementation.
  Delegates to `RbacGuard::project_id()`, which now returns from a single
  RwLock snapshot (Codex P1 atomicity contract preserved end-to-end).

- The trait contract requires *live* reads (not captured snapshots), so
  scope changes between Sender construction and `send()` must propagate.
  `test_scope_provider_reflects_post_update_change` locks this in.

Tests: 1407 → 1410 (+3 ScopeProvider tests). cargo clippy -D warnings clean.

No public API change to ActionSender yet — that lands in Step 9 when the
sender accepts `Arc<dyn ScopeProvider>` and stamps DispatchedAction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stop checklist commit on the correct branch (per feedback_branch_policy.md
and feedback_stop_checklist.md).

Audit log entries:
- codex-review-rerun (post-P1-hotfix verdict=approved)
- phase6-step8-complete (commit 73b347d, tests 1410, clippy clean)
- session-end (next resume = Phase 6 Step 9, 8 commits clean baseline)
- cross-session-handoff (resume instructions)
- system-followup-bl (BL-097 in devflow-aidlc-like — issue #189, PR #190)
- main-revert-incident (transparency: an earlier markers commit landed on
  main by mistake; reverted on main as bec4c05 and re-applied here)

Code-plan: Step 8 marked [x] (Phase 6 Step 8 ScopeProvider trait done).

Next resume point: Phase 6 Step 9 — ActionSender 시그니처 교체 +
스탬핑 (Step 10과 묶어 한 사이클 권장; app.rs 다수 호출부 컴파일
에러 도미노 예상).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r 주입

Wire FR2 origin stamping into the central ActionSender so every mutation
Action carries the project_id active at send time. The worker (Step 11)
will reject any DispatchedAction whose origin disagrees with the live
active scope — the structural fix for the cross-project leak diagnosed
in BL-P2-085.

Channel migration:
- `mpsc::UnboundedSender<VersionedEvent<Action>>` →
  `mpsc::UnboundedSender<VersionedEvent<DispatchedAction>>`
- Same for the matching receiver. `ActionReceiver::recv` keeps its
  `Option<Action>` external signature by stripping `.action` internally,
  so all ~100 module test sites using `test_action_channel()` compile and
  pass unchanged.

ActionSender:
- New 3rd `new` arg: `scope_provider: Arc<dyn ScopeProvider>`.
- `send(action)` keeps its public signature; internally it consults
  `crate::worker::action_is_mutation` (Phase 5 Step 7's exhaustive
  classification) and `scope_provider.current_project_id()` to wrap the
  Action as `DispatchedAction::stamped` (mutation + Some scope) or
  `unstamped` (read-only OR unscoped provider).
- Stamping happens at *send time* using the live provider, satisfying
  the contract test `test_scope_provider_reflects_post_update_change`.

ScopeProvider trait location refinement:
- Implementation moved from `Arc<RbacGuard>` to `RbacGuard` itself so
  `Arc<RbacGuard>` coerces directly to `Arc<dyn ScopeProvider>` via the
  standard `Arc<T> → Arc<dyn Trait>` unsized coercion. Step 8 tests
  updated to use auto-deref method-call form.

main.rs (Step 10):
- `RbacGuard` Arc constructed up front and shared across `ActionSender`
  (FR2 stamping) and `App::from_registry` (RBAC) and `run_worker` (RBAC).
  `update_roles` after auth is observed live by the sender's provider.

worker.rs (compat-only Step 10 piece):
- `run_worker` channel type updated to `<DispatchedAction>` and unwraps
  `dispatched.action` to keep existing handle_action logic intact.
  Step 11 will read `dispatched.origin_project_id` to gate mutations.
- `action_is_mutation` `#[allow(dead_code)]` removed — now wired.

3 RED → GREEN tests added in `context::action_channel::tests`:
- `test_sender_stamps_mutation_with_current_scope` (scope=A → origin=Some("A"))
- `test_sender_leaves_readonly_unstamped` (FetchServers → None)
- `test_sender_handles_unscoped_provider_returns_none_origin` (no roles → None)

Test count: 1410 → 1413 (+3). cargo clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stop checklist commit (per feedback_stop_checklist.md, on the correct
worktree branch — branch verified before commit).

Audit log entries:
- phase6-step9-10-complete (commit 1f80968, tests 1413, clippy clean)
- session-end (3rd stop, option D — Step 11 deferred to next session)
- cross-session-handoff (resume instructions with branch verify step)
- step-11-scope-decision (Step 11 scope ~$2 / 300-500 lines, recommend
  3-cycle split 11a signature+hook / 11b audit / 11c toast for token
  distribution and per-cycle external review opportunity)

Code-plan: Step 9 + Step 10 marked [x] (single atomic commit 1f80968).

Next resume point: Phase 7 Step 11 — run_worker C5 pseudo-flow +
AuditLogger 통합. Recommended sub-split:
  11a: signature +3 args (audit_logger / actor_cloud / actor_user_id)
       + origin guard hook (no audit/toast yet) + 2 tests
  11b: AuditLogger integration + CrossProjectBlockEvent::new helper
       + 1 test (no_audit_logger behavior)
  11c: AppEvent::CrossProjectBlocked + generate_toast mapping
       + 2 tests (toast order + readonly bypass)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `worker::check_dispatched_origin(&DispatchedAction, &RbacGuard)`
helper that reuses `cross_project_guard::check_origin_scope` and wire
it into `run_worker` as a single `if let GuardDecision::Block { .. }
{ continue; }` after envelope split. No signature change, no audit,
no toast — Step 11b will add `AuditLogger::emit` on Block, Step 11c
will add the `AppEvent::CrossProjectBlocked` toast path.

Tests:
- test_worker_allows_mutation_when_origin_matches
- test_worker_blocks_mutation_when_origin_mismatch

Codex review (Phase 6 Step 9+10) verdict=approved before entry —
saved to ~/projects/docs/reviews/2026-04-29-bl-p2-085-phase6-step9-10-feat-bl-p2-085-codex.md.

Tests: 1413 → 1415 pass, clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… block

Wire `CrossProjectBlockEvent` emission through the existing `AuditLogger`
when the FR2 origin guard rejects a dispatched mutation.

Helpers
- `CrossProjectBlockEvent::new(reason, layer, action_type, resource_kind,
  actor_cloud, actor_user_id, active, origin, correlation_id)` —
  convenience constructor that stamps `Utc::now()` and leaves
  resource-bound optional fields (`target_project_id`, `resource_id`,
  `resource_name`) as `None` for caller mutation.
- `worker::emit_origin_block_audit(reason, &dispatched, &rbac, logger,
  cloud, user_id, correlation_id)` — sync helper that builds the event
  and calls `cross_project_audit::emit`. Best-effort: logger=None falls
  back to `tracing::warn!`.

Wiring
- `run_worker` signature gains `audit_logger: Option<Arc<AuditLogger>>`,
  `actor_cloud: String`, `actor_user_id: String`. The Block branch in
  the recv loop now calls `emit_origin_block_audit` (correlation_id =
  `action_epoch`) before `continue`.
- `App.audit_logger` switches from `Option<AuditLogger>` to
  `Option<Arc<AuditLogger>>` and exposes `audit_logger_arc()` so the
  worker shares the same instance — two parallel `BufWriter`s on the
  same path would interleave writes.
- `main.rs` populates `actor_user_id` from the configured username
  (matches `App::build_audit_entry`); a follow-up resolves the Keystone
  UUID and adds context-switch refresh. `resource_kind` is left as ""
  with an inline TODO — Step 11c follow-up enriches per-action.

Reviewer Must-fix #1 (guard_layer + correlation_id) satisfied: events
carry `details.guard_layer="fr2_worker"` and
`details.correlation_id=<envelope.epoch()>`.

Tests
- `cross_project_audit::tests::test_new_convenience_constructor_fills_required_fields_and_now_timestamp`
- `worker::tests::test_emit_origin_block_audit_writes_entry_when_logger_present`
  (asserts cloud/user/project/result/details.guard_layer/correlation_id)
- `worker::tests::test_emit_origin_block_audit_does_not_panic_when_logger_none`

1415 → 1418 pass, clippy clean. Pre-existing keystone_project_directory
flake observed once (retry-pass; BL-P2-086 candidate, not a regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oast

Surface worker FR2 origin-block decisions to the UI via a new
`AppEvent::CrossProjectBlocked { reason: String, action: String }`
variant and an Error-level toast (parity with `PermissionDenied`).

Helpers
- `worker::make_cross_project_blocked_event(&CrossProjectReason, &Action)
  -> AppEvent` keeps the variant decoupled from `cross_project_guard`
  so the UI layer never imports the guard module.
- `App::generate_toast` adds a single arm: "Cross-project block:
  {action} ({reason})" → `ToastLevel::Error`. Inherits the longer Error
  TTL and shows up in the activity log as a failure entry.

Wiring
- `run_worker` Block path final shape: `emit_origin_block_audit` →
  `event_tx.send(VersionedEvent::new(AppEvent::CrossProjectBlocked,
  action_epoch))` → `continue`. Audit precedes the UI event so the
  structured entry lands even when the receiver has been dropped.
  Both happen synchronously before `continue`, so no `ApiError` for
  this dispatch can race ahead of the block notification.

Tests
- `worker::tests::test_worker_allows_readonly_without_guard` —
  `DispatchedAction::unstamped` skips the origin guard regardless of
  `RbacGuard` scope state.
- `worker::tests::test_make_cross_project_blocked_event_carries_reason_and_action`
  — helper-level conversion check.
- `app::tests::test_generate_toast_for_cross_project_blocked_pushes_error`
  — end-to-end `handle_event` exercise: activity log entry exists,
  not-success, message contains the reason slug.

1418 → 1421 pass (single-threaded; pre-existing
`keystone_project_directory` mock-isolation flake observed once
multi-threaded — BL-P2-086 candidate, not a BL-P2-085 regression).
clippy clean.

Phase 7 complete: Step 11 split into 11a (hook) + 11b (audit) + 11c
(toast), 8 new tests total (1413 → 1421).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…back parity

Address Codex + cargo-review (Multi-Agent) findings on Phase 7
(Step 11 a/b/c) before entering Phase 8.

Correctness MED #1 — main.rs username fallback inconsistency
- `actor_user_id` previously fell back to `String::new()` when the
  configured credential lacked a username, while `App::build_audit_entry`
  uses `"unknown"`. Worker-emitted audit lines therefore had `user=""`
  while app-side success entries had `user="unknown"` for the same
  account. Standardise on `"unknown"` and reuse `wire_username` so
  `AuthMethod::ApplicationCredential.id` is honoured.

Correctness MED #2 — stale actor capture survives cloud-switch
- `actor_cloud` and `actor_user_id` were captured as owned `String`s at
  worker spawn. After a runtime cloud-switch (BL-P2-074) the worker
  kept stamping audit lines with the spawn-time cloud forever. New
  `worker::ActorContext { cloud, user_id }` shared as
  `Arc<RwLock<ActorContext>>` is read at each `emit_origin_block_audit`
  call, and `App::handle_event` now updates `actor_ctx.cloud` inside
  the `ContextChanged` arm so the next block reflects the new cloud.

Style #9 — variable rename
- `audit_logger_for_worker` → `audit_logger` in main.rs (consistent
  brevity with neighbouring `actor_ctx`).

Wiring
- `run_worker` signature: drop `actor_cloud: String`/`actor_user_id:
  String`, take `actor_ctx: Arc<RwLock<ActorContext>>` instead.
- `App.actor_ctx` field + `set_actor_ctx()` setter installed from
  main.rs after construction.

Tests
- New: `worker::tests::test_emit_origin_block_audit_picks_up_actor_context_mutation`
  — emit twice, mutate `ActorContext.cloud` between, assert the second
  audit line carries the post-switch cloud.
- Existing 11b emit tests migrated to the `actor_ctx_with` helper.

1421 → 1422 pass (single-threaded; pre-existing BL-P2-086
keystone_project_directory mock flake unaffected). Clippy clean.

Follow-ups still open: resource_kind enrichment, Keystone UUID
resolution, token-refresh hook for user_id (cloud already live).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7 (Step 11 a/b/c + Codex/cargo-review polish) 자연 게이트에서
일시 정지. Atomic security 결정에 따라 push/PR 없이 stop.

- HEAD: 0b63233 (폴리싱 commit)
- Tests: 1422 pass single-threaded
- Reviews: Codex APPROVED + cargo-review APPROVE (HIGH 0, MED 2 → polished)
- Next: Phase 8 Step 12 — Neutron `_filter` IGNORE 패턴 fix
- 위치: src/adapter/http/neutron.rs:225/231/240

devflow-state.md (gitignore)는 별도 수동 갱신.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix the Neutron list builders that previously ignored their `_filter`
argument, so `:list_*` calls now scope to the active project on the
server side. This restores defense-in-depth alongside the FR2 worker
origin guard (Phase 7) and is a prerequisite for Step 13 response
refilter + AdapterFilterViolation events.

Bug surface
- src/adapter/http/neutron.rs:225/231/240 — `build_network_query`,
  `build_security_group_query`, `build_floating_ip_query` discarded
  `filter` (`_filter` prefix) and emitted pagination only. Admin tokens
  saw all projects regardless of the active scope, contradicting Nova
  / Cinder behaviour. RED-time discovery (2026-04-27).

Policy
- `filter.all_tenants == true`  → query contains `all_tenants=1`,
  no `tenant_id` (admin opt-out, mutually exclusive).
- `filter.all_tenants == false && filter.tenant_id = Some(scope)`
                                → query contains `tenant_id={scope}`.
- `filter.all_tenants == false && filter.tenant_id = None`
                                → pagination only (fail-safe — server
                                  keeps its default scoping under the
                                  current admin token).

Filter struct extension (src/port/types.rs)
- `NetworkListFilter`, `SecurityGroupListFilter`, `FloatingIpListFilter`
  gain `pub tenant_id: Option<String>` next to the existing
  `all_tenants: bool`. `Default` keeps `tenant_id = None`.

Worker enrichment (src/worker.rs)
- `run_worker` snapshots `rbac.project_id()` per dispatch and passes it
  into `handle_action` as `active_tenant: Option<String>` via the spawn
  closure. `handle_action` derives `scoped_tenant_id = if at { None }
  else { active_tenant.clone() }` so the read-side scope matches the
  worker mutation guard's view of the world.
- 3 Neutron list call sites (`FetchNetworks`, `FetchSecurityGroups`,
  `FetchFloatingIps`) now populate `tenant_id`.

Tests (+4 net = +7 RED − 3 stale)
- New: 7 RED tests in `src/adapter/http/neutron.rs::tests` covering
  the policy matrix above (3 builders × {inject, all_tenants_true skips
  tenant_id, no-op for security_group}).
- Removed: `test_build_network_query_no_all_tenants_param`,
  `test_build_security_group_query_no_all_tenants_param`,
  `test_build_floating_ip_query_no_all_tenants_param` — they pinned the
  ignored-`_filter` regression as intended behaviour. The new
  `*_all_tenants_true_skips_tenant_id` triplet asserts the correct
  inversion.
- Subnet listing is unchanged (Q1 matrix decision: `list_subnets`
  scopes via the parent network/device).

Verification
- `cargo test --lib -- --test-threads=1`: 1422 → 1426 pass, 0 fail.
- `cargo clippy --all-targets -- -D warnings`: clean.
- Files touched are fmt-clean (pre-existing fmt diffs in app.rs /
  action_channel.rs / etc. are leftovers from earlier phases and will
  be batched in the Phase 11 final pass).

Atomic security envelope: still no push / no PR until Phase 11 completes
(per state.md A안). Single PR contract intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
audit.md only — Step 12 complete + 5회차 session-end + handoff
markers. devflow-state.md is gitignored and updated separately.

- phase8-step12-complete: 1426 pass, +4 net (+7 RED − 3 stale),
  commit fa5efaa, neutron tenant_id injection working as designed
- session-end: mid-cycle stop after Step 12 (option A — incremental
  safety, no push). Atomic security envelope intact.
- next-resume: Phase 8 Step 13 — adapter response refilter +
  HasTenantId trait + AdapterFilterViolation audit event
  (code-plan.md line 313~).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the cross-project response refilter as a self-contained
pure helper. This is the foundation for Step 13b/14, where Neutron
(networks / security groups / floating ips) and Nova/Cinder
(servers / volumes / snapshots) list endpoints will plumb their
responses through `refilter_by_scope` and emit per-item
`CrossProjectBlockEvent`s with reason `AdapterFilterViolation` when
the server returns rows outside the active project.

Why a separate cycle for 13a
- Step 13b touches every Neutron list endpoint plus the
  `NeutronHttpAdapter` constructor (new `audit_logger` field) and
  every call site in app.rs / main.rs. Splitting the pure helper
  out keeps that blast radius from contaminating this commit and
  gives reviewers a focused unit (~140 LoC + 5 tests, no external
  dependencies) before the wiring lands.

API surface (`src/adapter/http/scope_refilter.rs`, new)
- `pub trait HasTenantId` — `tenant_id() -> Option<&str>` /
  `resource_id() -> Option<&str>`. Implementors land in 13b/14 for
  Network / SecurityGroup / FloatingIp / Server / Volume / Snapshot.
- `pub fn refilter_by_scope<T: HasTenantId>(items, active, all_tenants)
  -> (Vec<T>, Vec<T>)` — partitions into `(kept, dropped)`.

Policy
- `all_tenants == true`         → no-op (admin opt-out).
- `all_tenants == false && active.is_none()` → no-op (no ground
  truth; worker-side guard already blocks mutations via
  UnscopedFailSafe).
- `all_tenants == false && active.is_some()` → strict refilter.
  Items whose `tenant_id() == active` go to `kept`; everything else
  — including items with `tenant_id() == None` — goes to `dropped`
  (fail-safe: cannot prove the row belongs to the active scope).

Allocation strategy
- `kept = Vec::with_capacity(items.len())` (common path: zero drops).
- `dropped = Vec::new()` (lazy — only allocates on first reject).
- The doc comment explicitly warns reviewers off
  `Iterator::partition`, which pre-allocates both sides.

Audit hand-off contract (documented for 13b)
- `dropped.iter()` produces every row the caller MUST emit an
  `AdapterFilterViolation` event for, even when the row's
  `tenant_id() == None` — the audit chain depends on every drop
  being attributable.

Tests (+5, 1426 → 1431)
- `test_refilter_drops_cross_project_items_when_scope_strict`
- `test_refilter_keeps_all_when_all_tenants_true`
- `test_refilter_keeps_active_scope_items`
- `test_refilter_drops_items_with_missing_tenant_id_when_strict`
  (fail-safe — `tenant=None` rejected when active is set)
- `test_refilter_no_op_when_active_none`
  (active=None short-circuits, items pass through untouched)

Doc polish from cargo-review (Multi-Agent: Correctness/Style/
Suggestions, verdict APPROVE-WITH-MINOR)
- Style MED #2 — `tenant_id` / `resource_id` trait methods now
  carry their own `///` docs (clippy `missing_docs` parity).
- Suggestions IDIOM #1 — fn-level doc warns against
  `Iterator::partition` regression.
- Suggestions SECURITY #6 — module-level doc spells out the
  None-tenant_id-must-still-emit contract for Step 13b.

Deferred to Step 13b decision points (real call sites available)
- `RefilterScope { active, all_tenants }` struct vs. positional
  args (Suggestions READABILITY #2).
- `resource_id() -> Option<&str>` vs. `&str` once we know the
  Network / Server / Volume model id shapes (Suggestions IDIOM #3).
- Trait rename `HasTenantId` → `RefilterableItem` /
  `ScopedItem` (Suggestions READABILITY #4).

Verification
- `cargo test --lib -- --test-threads=1`: 1426 → 1431 pass.
- `cargo clippy --all-targets -- -D warnings`: clean.
- New file is fmt-clean; pre-existing fmt diffs elsewhere remain
  scheduled for the Phase 11 batch pass.

Atomic security envelope intact: still no push / no PR until
Phase 11 completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… 13b RED only)

audit.md only — Step 13a complete + Step 13b RED-only (option C
incremental impact measurement) + 6회차 session-end + handoff
markers. devflow-state.md is gitignored and updated separately.

- phase8-step13a-complete: 1431 pass (+5), commit 021dbe0,
  scope_refilter pure helper with cargo-review APPROVE-WITH-MINOR
  (3 doc polishes inlined; 4 suggestions deferred to 13b decision
  points).
- phase8-step13b-RED-only: 5 RED tests added to
  scope_refilter.rs::tests for HasTenantId impl on
  Network/SecurityGroup/FloatingIp. RED verified = 10 E0599
  compile errors (method tenant_id/resource_id not found).
  Intentional broken build — restored by 13b GREEN next session.
  Impact scope measured = 4 files (scope_refilter.rs +impls /
  neutron.rs +audit_logger field +list_* wiring +emit /
  registry.rs new_http sig / main.rs caller).
  NeutronHttpAdapter::from_base call sites = registry.rs:64 only
  (smaller blast than initial estimate).
- session-end: option C — RED only + impact measurement, no push.
  Atomic security envelope intact.
- next-resume: Phase 8 Step 13b GREEN. Step 1: add 3 HasTenantId
  impls (1431 → 1436). Step 2: NeutronHttpAdapter audit_logger
  field + list_* refilter wiring + emit. Step 3: registry/main
  caller updates.

Uncommitted on resume: src/adapter/http/scope_refilter.rs (+85
lines RED tests). Run `cargo test --lib --no-run` to confirm
intentional RED before adding impls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st models

GREEN-only commit that turns the 5 RED tests staged in 6회차 into
passing assertions. No new tests, no signature changes — strictly
trait-impl-and-restore-build.

What
- `impl HasTenantId for Network` / `SecurityGroup` / `FloatingIp` in
  `src/adapter/http/scope_refilter.rs`. All three share the same
  shape: `tenant_id()` delegates to `self.tenant_id.as_deref()`
  (`Option<String>` → `Option<&str>`), `resource_id()` returns
  `Some(&self.id)` (id is always present on the wire).

Why now (sub-cycle split)
- 13b-2 still has the heavyweight changes ahead — `NeutronHttpAdapter`
  gains `audit_logger` and `scope_provider` fields, every Neutron
  list_* impl wires `refilter_by_scope` + per-drop
  `cross_project_audit::emit`, and 13b-3 propagates the constructor
  change through `registry::new_http` and `main.rs`. Landing the
  trait impls first restores a green build (1431 → 1436) and gives
  the next commit a clean diff focused on the adapter rewrite.

Tests (1431 → 1436)
- The 5 tests committed in the 6회차 RED-only step now pass:
  - `test_network_has_tenant_id_returns_some_when_present`
  - `test_network_has_tenant_id_returns_none_when_absent`
  - `test_security_group_has_tenant_id_returns_some_when_present`
  - `test_floating_ip_has_tenant_id_returns_some_when_present`
  - `test_floating_ip_has_tenant_id_returns_none_when_absent`
- `cargo test --lib --no-run` no longer produces 10 E0599s.
- `Network`/`SecurityGroup`/`FloatingIp` confirmed (RED-time):
  every model carries `tenant_id: Option<String>` and `id: String`,
  so `as_deref()` + `Some(&self.id)` is the correct mapping with no
  per-type divergence.

Verification
- `cargo test --lib -- --test-threads=1`: 1431 → 1436 pass.
- `cargo clippy --all-targets -- -D warnings`: clean.
- File is fmt-clean.

Atomic security envelope intact: still no push / no PR until
Phase 11 completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
audit.md only — 7회차 resume + Step 13b-1 complete + 13b-2 design
frozen (not implemented this session) + session-end + handoff.
devflow-state.md is gitignored and updated separately with the
"Step 13b-2 Design (FROZEN)" section.

- session-resumed: tests-at-HEAD=1431, RED uncommitted confirmed
  10 E0599 as designed.
- phase8-step13b-1-complete: HasTenantId for Network /
  SecurityGroup / FloatingIp, all same shape, 6회차 RED 5 tests
  green, 1431 → 1436, commit 0e66271.
- step13b-2-design-frozen: full design (NeutronAuditCtx struct +
  emit_filter_violations<T:HasTenantId> + Adapter audit_ctx field
  + with_audit builder + 3 list_* wiring pattern + 5 RED test
  plan with tempdir AuditLogger fixture) recorded in state.md so
  the next session can drop straight into RED.
- session-end: user pause before 13b-2 code. Clean tree, no
  broken build. Atomic security envelope intact.
- next-resume: Phase 8 Step 13b-2 RED → GREEN per FROZEN design,
  then 13b-3 (registry/main caller) as a separate commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t_* refilter

Wire the response-side defense-in-depth on every Neutron list endpoint.
After Step 12 made the server side scope `tenant_id={active}`, this
commit closes the loop on the client: each `list_*` runs the response
through `refilter_by_scope` and emits a per-row
`AdapterFilterViolation` event when the server still leaks a foreign
project's row.

Why a separate context type
- `NeutronAuditCtx` bundles the three `Arc`-shared pieces every Neutron
  list call needs (`AuditLogger`, `ScopeProvider`, `ActorContext`)
  without bloating the adapter struct or the `NeutronPort` trait
  signature. The same shape will be reusable for Nova/Cinder in
  Step 14.

API surface (`src/adapter/http/neutron_audit.rs`, new)
- `pub struct NeutronAuditCtx { logger, scope_provider, actor_ctx }`
- `impl NeutronAuditCtx::emit_filter_violations<T: HasTenantId>(
      dropped, action_type, resource_kind, correlation_id)`
  - No-op on `dropped.is_empty()` (common path: zero violations).
  - Reads `actor_ctx` and `scope_provider` *at emit time* so a
    cloud/project switch between the list call and the emit is
    reflected immediately (parity with Phase 7 polish #2).
  - Builds `CrossProjectBlockEvent::new(...)` with reason
    `AdapterFilterViolation { resource_id, project_id }` and
    `GuardLayer::Fr1Adapter`. `asserted_origin_project_id` stays
    `None` — adapters have no origin information, only target rows.
  - `emit` is best-effort: mirrors the worker-side path in
    `cross_project_audit::emit`, falling back to `tracing::warn!` on
    logger failure.

NeutronHttpAdapter wiring
- New `audit_ctx: Option<Arc<NeutronAuditCtx>>` field. `from_base` and
  `new` keep the existing call shape (`audit_ctx = None`); `new`
  builder `with_audit(ctx)` attaches a context. Step 13b-3 wires this
  through `registry::new_http`; until then the adapter behaves as a
  pre-13b passthrough.
- Private helper `refilter_response<T: HasTenantId>(resp, all_tenants,
  action_type, resource_kind) -> PaginatedResponse<T>`. No-op when
  `audit_ctx` is None; otherwise:
    1. Reads `active = scope_provider.current_project_id()` live.
    2. Runs `refilter_by_scope` against `resp.items`.
    3. Calls `emit_filter_violations(&dropped, ...)`.
    4. Reassembles `PaginatedResponse { items: kept, next_marker,
       has_more }` so caller-visible pagination is preserved.
- `list_networks` / `list_security_groups` / `list_floating_ips` each
  funnel their `paginated_list` result through `refilter_response`
  with their own `(action_type, resource_kind)` labels. Other Neutron
  list endpoints (subnets / ports / agents) stay untouched — their
  scoping path is parent-resource-bound and not Step 13's surface.

`correlation_id` is currently `0`. Phase 8 doesn't have a worker
dispatch epoch threaded into the adapter call chain yet — adding
`Option<u64>` to the `NeutronPort` list_* signatures would balloon
the blast and isn't needed for the AdapterFilterViolation use case
(the canonical fingerprint already disambiguates per-row events).
A follow-up cycle can revisit if cumulative review flags it.

Tests (1436 → 1441, +5)
- `test_neutron_audit_ctx_emit_one_event_per_dropped` — 3-row drop
  → 3 audit lines, each carrying its own `resource_id`.
- `test_neutron_audit_ctx_no_emit_when_dropped_empty` — zero rows
  → audit log untouched.
- `test_neutron_audit_ctx_uses_fr1_adapter_layer_in_event` —
  `details.guard_layer == "fr1_adapter"`.
- `test_neutron_audit_ctx_uses_adapter_filter_violation_reason` —
  `result.failed == "cross_project_block:adapter_filter_violation"`.
- `test_neutron_with_audit_attaches_ctx_default_none` — verifies
  `with_audit` is callable with the expected signature.
- All five use the production `AuditLogger` against a `tempfile::
  TempDir` (mirrors `cross_project_audit::tests::
  test_emit_with_logger_writes_audit_entry`).

Verification
- `cargo test --lib -- --test-threads=1`: 1436 → 1441 pass.
- `cargo clippy --all-targets -- -D warnings`: clean.

Atomic security envelope intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gistry/main

Caller-side closure for Step 13b. Threads the
`Arc<NeutronAuditCtx>` from the App-owned audit logger and the live
`RbacGuard` / `ActorContext` arcs into `AdapterRegistry::new_http`,
which now attaches it to the `NeutronHttpAdapter` via `with_audit`.
End-to-end: a Neutron list response that leaks a foreign project's
row now produces a fingerprinted `AdapterFilterViolation` audit
entry without any code path opting in.

Why the main.rs reordering
- Pre-13b-3: `AdapterRegistry::new_http` was called immediately after
  the auth provider was built (before App / audit_logger were
  available). 13b-3 needs the audit logger and `actor_ctx` *before*
  the registry, so the call moved past `App::from_registry` +
  `audit_logger_arc()` + `actor_ctx` construction. `cloud_region`
  is captured up-front so the `cloud` borrow can release before
  `config` moves into `App::from_registry` (E0505 otherwise).

API change (`src/adapter/registry.rs`)
- `AdapterRegistry::new_http(auth, region, neutron_audit:
  Option<Arc<NeutronAuditCtx>>) -> Result<Self, ApiError>` — the
  third parameter is `None`-able. When `Some`, the registry runs
  `NeutronHttpAdapter::from_base(...).with_audit(ctx)` so the
  Neutron adapter inside `Arc<dyn NeutronPort>` already carries
  `audit_ctx` from construction (no interior mutability needed,
  no post-hoc Arc surgery).

Wiring (`src/main.rs`)
- `audit_logger.clone().map(|logger| Arc::new(NeutronAuditCtx {
    logger, scope_provider: rbac.clone() as Arc<dyn ScopeProvider>,
    actor_ctx: actor_ctx.clone() }))`
- The `Arc<RbacGuard> -> Arc<dyn ScopeProvider>` coercion (Step 8)
  means the same `rbac` instance feeds FR2 worker stamping,
  ScopeProvider live-read, and now the adapter refilter — single
  source of truth for active project id.
- `actor_ctx` is shared by reference (Arc) so a runtime cloud
  switch via `App::handle_event` (BL-P2-074) is observed by the
  adapter on the very next list call (no rebind required).

Other registry consumers unchanged
- `AdapterRegistry::new_mock` is `#[cfg(test)]` and does not call
  `new_http`, so no test fixture changes are required. Tests that
  care about audit emission already construct `NeutronAuditCtx`
  directly (Step 13b-2).

Tests (1441 → 1441, no new tests)
- 13b-3 is wiring only; the behavior was test-covered in 13b-2.
  Full suite remains green at 1441.
- `cargo build` (binary) compiles — verifies the borrow
  reorganization in `main.rs` doesn't regress the existing flow.

Verification
- `cargo test --lib -- --test-threads=1`: 1441 pass, 0 fail.
- `cargo clippy --all-targets -- -D warnings`: clean (covers the
  `nexttui` binary, not just the lib).

Phase 8 (Steps 12 + 13a + 13b-1 + 13b-2 + 13b-3) is now feature-
complete on the BL-P2-085 branch. Next steps: Step 14 Nova/Cinder
defense-in-depth refilter, then Phase 9/10/11. Atomic security
envelope intact — still no push / no PR until Phase 11 completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…back

Polish-only commit applying the immediate-action subset of the Phase 8
cumulative cargo-review (Multi-Agent Full, base=9b79a99 → fd2d2e5,
850 lines, verdict APPROVE-WITH-MINOR + Step-14-refactor-precedent).
Behavioral changes: zero. Test count unchanged at 1441.

Style MED #1 — NeutronAuditCtx field docs
- All three `pub` fields of `NeutronAuditCtx` now carry one-line `///`
  comments. The trait `HasTenantId` already documents its methods;
  the struct fields lagged behind. Keeps the type self-documenting
  for Step 14, which will likely add Nova/Cinder mirrors.

Style LOW #5 — correlation_id=0 TODO
- The `0` placeholder in `NeutronHttpAdapter::refilter_response` now
  carries a comment explaining (a) why it's safe today (canonical
  fingerprint already disambiguates per-row events via resource_id),
  (b) when it should be replaced (worker→adapter epoch propagation,
  natural slot is the post-Step-14 refactor cycle). Visible to grep
  and to the next reader who wonders why the value is hardcoded.

Style LOW #6 — main.rs path shortening
- Add `use nexttui::adapter::http::neutron_audit::NeutronAuditCtx;`
  and `use nexttui::context::action_channel::ScopeProvider;`. The
  `audit_logger.clone().map(...)` block in the registry construction
  now reads on a single screen with no fully-qualified paths.

Style LOW #7 — scope_refilter.rs tests imports
- Move `use crate::models::neutron::{FloatingIp, Network,
  SecurityGroup};` to the top of `mod tests` next to `use super::*`.
  Drops the historical "Step 13b RED: ..." comment block (the impls
  are merged; the comment described an intermediate state) and keeps
  a one-paragraph note describing what the tests assert.

Correctness LOW #3 / Suggestions READABILITY #4 — resource_id duplicate
- The post-construction `event.resource_id = Some(resource_id);` mutation
  now carries a comment explaining why the adapter path promotes the row
  id into the canonical fingerprint slot (Step 11b ctor leaves it None
  by design — worker case can't always know it; adapter always can).
  No structural change — extending `CrossProjectBlockEvent::new` to
  accept `Option<String>` for `resource_id` would force every existing
  worker call site to update and is deferred to the Step-14-precedent
  refactor cycle alongside the Builder-vs-positional decision.

Deferred to Step-14-precedent refactor cycle (cumulative review verdict)
- Suggestions DRY #1 / #2 / #8: lift `refilter_response` into
  `scope_refilter::refilter_and_audit<T, A>`, turn `NeutronAuditCtx`
  into a generic `AuditCtx { logger, scope_provider, actor_ctx,
  service }` with three type aliases, and absorb the registry
  signature growth into `AdapterAuditConfig`. All three become free
  before duplicating the pattern across Nova/Cinder in Step 14.
- Suggestions IDIOM #10 (13a deferred 4 — `RefilterScope` struct,
  trait rename `HasTenantId` → `ScopedItem`, `resource_id` keep,
  fixture helper keep): re-evaluated with real call-site evidence
  in the Step-14-precedent cycle.
- Style LOW #2 (cloud/user_id clone count) collapses naturally when
  emit_filter_violations moves into the generic `refilter_and_audit`.

Verification
- `cargo test --lib -- --test-threads=1`: 1441 → 1441 (no test
  changes; behavioral neutrality of polish confirmed).
- `cargo clippy --all-targets -- -D warnings`: clean.
- Removed temporary `.cargo-review.toml` (only existed to scope the
  cumulative review).

Atomic security envelope intact — still no push / no PR until
Phase 11 completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mplete + polish)

audit.md only — 8회차 resume + Step 13b-2 + Step 13b-3 + Phase 8
cumulative cargo-review + polish + session-end + handoff.
devflow-state.md is gitignored and updated separately with the
"Step-14-precedent-refactor-cycle (FROZEN)" 9-section plan.

Highlights from this session
- phase8-step13b-2-complete (commit f2e66f3): NeutronAuditCtx +
  emit_filter_violations + Adapter wiring, 1441 pass.
- phase8-step13b-3-complete (commit fd2d2e5): registry/main wired.
  Phase 8 feature-complete.
- phase8-cumulative-cargo-review: Multi-Agent Full, base=9b79a99 →
  fd2d2e5, 850 lines. Verdict APPROVE-WITH-MINOR-DOC-POLISH +
  Step-14-precedent-refactor-cycle.
- phase8-polish-complete (commit da38cb9): 5 immediate-action
  items applied (NeutronAuditCtx field docs / correlation_id=0
  TODO / main.rs use shortening / scope_refilter tests use
  re-grouped / event.resource_id duplicate-set comment). 1441
  stable, behavioral 변화 0.
- session-end: clean baseline, no broken build. Atomic security
  envelope intact.
- next-resume: Step-14-precedent-refactor-cycle (3 sub-cycles per
  state.md FROZEN section) → Step 14 Nova/Cinder defense-in-depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce `RefilterScope<'a>` with three ctor-validated states (`strict` /
`all_tenants` / `unscoped`) plus `from_parts` adapter that normalizes
`active=Some + all_tenants=true` to all_tenants. Raw fields private — the
ctor surface encodes the invariant the 5+ Step-14 callers will rely on.

Change `refilter_by_scope` signature `(items, active, all_tenants)` →
`(items, &RefilterScope)`; body switches to `scope.is_all_tenants()` /
`scope.active()`. Migrate the 5 existing scope_refilter tests and the
single prod call site (`NeutronHttpAdapter::refilter_response`,
`src/adapter/http/neutron.rs:73`) via `RefilterScope::from_parts(...)`.

4 new RED→GREEN tests cover each ctor + from_parts normalization.
1441 → 1445 pass, clippy clean. Sub-cycle 1/3 of the
Step-14-precedent-refactor-cycle agreed at 8회차 stop (Phase 8 cumulative
cargo-review verdict). Next: refactor-2 (AuditCtx generic + AuditEmitter
trait + refilter_and_audit free fn).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…filter_and_audit + AuditCtx generic

Generalize the audit context so Step-14 (Nova/Cinder) can reuse the
Neutron Step-13b plumbing without a per-service struct.

scope_refilter.rs
- Add `AuditEmitter<T: HasTenantId>` trait — caller-provided sink for
  per-row `AdapterFilterViolation` events. Generic over `T` so each
  adapter context handles its native list-item type without erasing
  `tenant_id` / `resource_id` to a `&dyn HasTenantId`.
- Add `refilter_and_audit<T, A>` free fn that colocates partition
  (`refilter_by_scope`) with the per-row audit emit. Returns only `kept`
  because the dropped vec is consumed by the audit path.
- 3 RED→GREEN tests via a `CountingEmitter` test double:
  emits_when_dropped_nonempty / skips_when_audit_none /
  skips_when_dropped_empty.

neutron_audit.rs
- Rename `NeutronAuditCtx` struct → `AuditCtx` (service-agnostic) and
  expose `pub type NeutronAuditCtx = AuditCtx;` plus
  `NovaAuditCtx` / `CinderAuditCtx` placeholders so callers keep
  service-named imports while the struct itself is shared.
- Convert the inherent `pub fn emit_filter_violations<T>` into
  `impl<T: HasTenantId> AuditEmitter<T> for AuditCtx`. Body unchanged —
  trait dispatch reproduces the exact same per-row semantics.
- Module docstring updated to reflect the generic ctx + Step-14 reuse
  story; refactor-3 will add the `service` discriminator.

neutron.rs
- Migrate `NeutronHttpAdapter::refilter_response` from the
  `refilter_by_scope` + `ctx.emit_filter_violations` 2-step to a single
  `refilter_and_audit` call with `audit_ctx.as_deref()`. Behavior
  preserved — when `audit_ctx` is None, `refilter_and_audit`'s
  `dropped.is_empty() && audit.is_some()` short-circuit reproduces the
  prior early-return.

Behavioral neutrality: 1445 → 1448 pass (+3 new), clippy clean, touched
files fmt clean. Sub-cycle 2/3 of the Step-14-precedent-refactor-cycle.
Next: refactor-3 (`AdapterAuditConfig` bundle + `registry::new_http`
signature + `main.rs::build_audit_config` helper + trait rename
`HasTenantId` → `ScopedItem` + `AuditCtx::service` field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g + build_audit_config + ScopedItem rename

Final sub-cycle of the Step-14-precedent-refactor-cycle. Bundles the
audit wiring into a single registry argument, colocates per-service
tagging in one helper, and renames the refilter trait so its name
matches its contract.

scope_refilter.rs / neutron.rs / neutron_audit.rs
- Rename trait `HasTenantId` → `ScopedItem` across 22 occurrences
  (trait def + 4 impls + 4 trait bounds + 2 imports + 7 docstring/
  comment refs). The contract is "this row participates in scope
  comparison", not "has a `tenant_id` field" — the new name lets Step 14
  cover Cinder models that may carry `project_id` instead.

neutron_audit.rs
- Add `service: &'static str` field to `AuditCtx` plus an
  `AuditCtx::service()` getter so the discriminator survives field
  privatization. Stamped by `build_audit_config` (`"neutron"` /
  `"nova"` / `"cinder"`); not on the audit wire today (would bump
  fingerprint canonical from v1).
- Add `AdapterAuditConfig { neutron, nova, cinder: Option<Arc<AuditCtx>> }`
  with `Default` so mock registries pass `AdapterAuditConfig::default()`.
- Add `build_audit_config(audit_logger, scope_provider, actor_ctx)` —
  the canonical constructor. Returns `Default` (all `None`) when
  `audit_logger` is `None`; otherwise stamps three `Arc<AuditCtx>` that
  share `logger` / `scope_provider` / `actor_ctx` and differ only in
  `service`. `scope_provider: Arc<dyn ScopeProvider>` (not
  `Arc<RbacGuard>`) so unit tests can mock with `FixedScope`.
- Tests: 3 new RED→GREEN — `default_all_none` /
  `returns_none_when_logger_none` /
  `returns_three_service_tagged_ctxs_when_logger_some`. Existing
  `build_ctx` fixture stamps `service: "neutron"` so the 5 prior
  emit_filter_violations tests keep passing.

scope_refilter.rs
- 1 new RED→GREEN — `test_scoped_item_trait_used_for_refilter_signature`
  pins the rename behaviorally (compile-fail without `ScopedItem`).

registry.rs
- `new_http` signature: `neutron_audit: Option<Arc<NeutronAuditCtx>>`
  → `audit: AdapterAuditConfig`. Body wires `audit.neutron` into
  `NeutronHttpAdapter::with_audit`; `audit.nova` / `audit.cinder` are
  documented as Step 14 follow-ups (Nova/Cinder lack `with_audit` until
  defense-in-depth refilter lands).

main.rs
- Replace the inline `Arc<NeutronAuditCtx>` construction with a single
  `build_audit_config(audit_logger.clone(), rbac.clone() as _,
  actor_ctx.clone())` call. Keeps the existing line-101 `ActionSender`
  formatting (Phase-11-final-pass scope) untouched.

Behavioral neutrality: 1448 → 1452 pass (+4 new), clippy clean, refactor-3
files fmt clean (pre-existing Phase-11 diffs preserved). Step-14-
precedent-refactor-cycle is feature-complete; next is Step 14
(Nova/Cinder defense-in-depth refilter — `ScopedItem` impls for Server/
Volume/Snapshot, `with_audit` builders on NovaHttpAdapter /
CinderHttpAdapter, list_* wiring through `refilter_and_audit`,
`audit.nova` / `audit.cinder` consumed in `registry::new_http`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bluejayA and others added 14 commits May 11, 2026 12:01
…em impls (Server/Volume/Snapshot)

Defense-in-depth response-side refilter for Nova `list_servers`, mirroring
the Neutron Step 13b plumbing now that the Step-14-precedent refactor
generalized `AuditCtx` / `AdapterAuditConfig`.

scope_refilter.rs
- `impl ScopedItem for Server` (`models::nova`), `Volume` /
  `VolumeSnapshot` (`models::cinder`). All three reuse the
  `tenant_id: Option<String>` field — Volume / Snapshot rename the
  upstream `os-vol-tenant-attr:tenant_id` /
  `os-extended-snapshot-attributes:project_id` wire fields, so the
  impl shape stays identical to Neutron's. Adding Volume / Snapshot in
  this cycle (not 14b) keeps the trait surface in one diff hunk;
  14b's Cinder wiring then needs only the adapter-side changes.
- 6 new RED→GREEN tests covering `tenant_id` / `resource_id` for each
  model in present / absent variants. Fixture helpers
  (`sample_server` / `sample_volume` / `sample_volume_snapshot`) live
  next to the Neutron equivalents.

nova.rs
- Add `audit_ctx: Option<Arc<NovaAuditCtx>>` field plus `with_audit`
  builder. `NovaAuditCtx` is the Step-14-precedent type alias for
  `AuditCtx`, so `audit.nova` flows through unchanged.
- Add `refilter_response<T: ScopedItem>` helper (mirrors Neutron's),
  calling `refilter_and_audit` with `RefilterScope::from_parts`. The
  pre-Step-14 behavior is preserved when `audit_ctx` is None — the
  helper still calls `refilter_and_audit`, but the `audit=None` /
  `dropped.is_empty()` short-circuit yields the original items.
  correlation_id=0 (list_* are not bound to a worker dispatch — same
  Phase-8 cumulative-review trade-off as Neutron).
- Wire `list_servers` through `refilter_response(resp,
  filter.all_tenants, "FetchServers", "server")` so a malicious or
  buggy upstream that ignores the server-side scope filter still gets
  rows dropped + per-row `AdapterFilterViolation` audit emits.

registry.rs
- `new_http` body now consumes `audit.nova` via `nova.with_audit(ctx)`.
  Documentation updated; `audit.cinder` follow-up flagged for 14b.

Behavioral neutrality: 1452 → 1458 pass (+6 new), clippy clean.
Touched files fmt clean (pre-existing Phase-11 diffs preserved).
Next: Step 14b — Cinder defense-in-depth (CinderHttpAdapter
audit_ctx + with_audit + refilter_response<T> + list_volumes +
list_snapshots wiring + registry.audit.cinder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lumes + list_snapshots)

Mirror of Step 14a (Nova) for the Cinder adapter. `ScopedItem` impls for
`Volume` and `VolumeSnapshot` already landed in 14a so the trait surface
stays in a single hunk — 14b only adds adapter-side wiring.

cinder.rs
- Add `audit_ctx: Option<Arc<CinderAuditCtx>>` field plus `with_audit`
  builder. `CinderAuditCtx` is the Step-14-precedent type alias for
  `AuditCtx`, so `audit.cinder` flows through unchanged.
- Add `refilter_response<T: ScopedItem>` helper (identical shape to
  Nova / Neutron); routes through `refilter_and_audit` with
  `RefilterScope::from_parts`. `audit_ctx=None` short-circuits the same
  way (no allocation, items returned unchanged).
- Wire `list_volumes` through `refilter_response(resp,
  filter.all_tenants, "FetchVolumes", "volume")` and `list_snapshots`
  through `refilter_response(resp, filter.all_tenants,
  "FetchSnapshots", "snapshot")`. Defense-in-depth: a malicious or
  buggy upstream that ignores the server-side `tenant_id` scope still
  gets rows dropped + per-row `AdapterFilterViolation` audit emits.
- correlation_id=0 inherits the Phase-8 cumulative-review trade-off
  shared by Neutron / Nova (list_* not bound to a worker dispatch).

registry.rs
- `new_http` body now consumes `audit.cinder` via
  `cinder.with_audit(ctx)`. The Step-14 plumbing is now complete on
  all three list-emitting adapters (Neutron / Nova / Cinder).

Behavioral neutrality: 1458 pass (no new tests — wiring inherits
behavioral coverage from the Step 13b-2 `AuditCtx::emit_filter_violations`
5-test suite via `pub type CinderAuditCtx = AuditCtx`). clippy clean.
Touched files fmt clean (pre-existing Phase-11 diffs preserved).

Step 14 is now feature-complete. BL-P2-085 worker (Phase 7) + adapter
(Phase 8 + Step 14) defense-in-depth chains are both wired. Next
phases per the A안 Atomic Security Fix plan: Phase 9 (form layer) →
Phase 10 (UI) → Phase 11 (bg / final fmt pass) → single atomic PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-string guard + docs + rename comment

Apply the 5 immediately-actionable items from the cargo-review report
(2 SECURITY + 3 Style polish) before continuing to Phase 9.

SECURITY (Sugg #1 + #2)
- `RefilterScope::strict("")` would silently drop every row, since no
  list model carries `tenant_id == ""`. Add `debug_assert!` to catch
  the caller bug in dev builds. Release builds rely on `from_parts`
  normalization (next item) — the sole production caller path.
- `RefilterScope::from_parts(Some(""), false)` previously routed
  straight into `strict("")`. Fail-safe normalize: empty string is
  treated as `None` (unscoped), so a `scope_provider` returning
  `Some("")` (partial token, edge session) results in the worker-side
  guard handling mutations rather than the adapter panicking.
- 2 new RED→GREEN tests: `_strict_panics_on_empty_active`
  (`#[should_panic]`) + `_from_parts_treats_empty_string_as_unscoped`.

Style polish
- Style #1: doc-comment for `RefilterScope::active()` and
  `is_all_tenants()` (getter contract was implicit).
- Style #2: doc-comment for `AuditEmitter::emit_filter_violations`
  trait method — contract was previously only in the impl on
  `AuditCtx`, leaving the trait def silent.
- Style #3: fix self-referential rename comment
  `ScopedItem → ScopedItem` (replace_all artifact from refactor-3)
  back to the intended `HasTenantId → ScopedItem`.

Behavioral neutrality on existing call sites: all production callers
(`{neutron,nova,cinder}.rs::refilter_response`) use `from_parts`, which
now normalizes empty strings before they reach `strict`. Existing
tests use non-empty literals (`"A"` etc.) and stay green.

1458 → 1460 pass, clippy clean, refactor-3 files fmt clean
(pre-existing Phase-11 diffs preserved). Deferred items from the
cargo-review report (3-way `refilter_response` consolidation,
`service: &str` → enum, ScopedItem impl macro, AdapterAuditConfig
enum invariant, race-window doc) tracked for the post-Phase-11 polish
cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…idator helper, FR4)

Introduce `src/module/common/scope_validator.rs` — the FR4 pre-dispatch
form-scope check that complements FR1 (adapter refilter) and FR2
(worker origin guard).

Module layout
- New `src/module/common/` package with `pub mod scope_validator;`.
  Registered in `src/module/mod.rs` between `aggregate` and
  `compute_service` (alphabetical).

Surface
- `FormSelection<'a> { field, project_id }` — one row of form input
  carrying its scope label. `project_id: Option<&str>` so missing-scope
  rows can fail-safe-deny without `&str` empty-sentinel ambiguity.
- `FormValidationError { field: String, reason: CrossProjectReason }`
  — first offending field plus the canonical
  `CrossProjectReason::FormSelectionMismatch` so the audit emit path
  (Phase 10 toast / future audit log entry) shares one schema with FR1
  and FR2.
- `pub fn validate_form_scope(active: &str, selections: &[FormSelection])
  -> Result<(), FormValidationError>` — iterate, return first mismatch.
  `project_id == None` encodes as `selected = ""` for the schema-stable
  audit string.

RED → GREEN
- 4 tests cover the four shapes the plan calls out:
  `_single_match_passes` / `_single_mismatch_returns_error` /
  `_multi_first_mismatch_wins` / `_validation_error_carries_field_name_and_reason`
  (`project_id: None` fail-safe). Verified RED with a bodyless
  `Ok(())` stub (3 fail, 1 spurious pass on the match case), then
  swapped in the real iterator body for full green.

Behavioral neutrality on existing call sites: none yet — Step 16
(Glance pre-mutation Delete/Update wiring) is the first consumer.

1460 → 1464 pass (+4 new), clippy clean, fmt clean on new files
(pre-existing Phase-11 diffs preserved).

Next: Step 16 — Glance Delete/Update pre-mutation GET → owner extract
→ `validate_form_scope` → `Fr4Form` audit emit + reject + toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…4 guard (worker.rs)

Pre-mutation owner check for `Action::DeleteImage` to prevent a stale
form-selected image from being deleted when its owner project no
longer matches the active scope (e.g. user switched clouds/projects
between list refresh and confirm).

Plan adjustment vs code-plan.md Step 16
- Plan placed the hook inside `src/module/image/mod.rs`, but
  `ImageModule` doesn't carry an adapter handle — every mutation
  flows through `ActionSender::send` → worker. The natural FR4 hook
  is therefore worker-side, where the GlanceAdapter is already in
  scope along with `audit_logger` and `actor_ctx` (Step 11b
  plumbing). Also descoped `UpdateImage` to a follow-up BL because
  no `Action::UpdateImage` variant exists today.

worker.rs surface (new helpers, mirror of FR2 Step 11b shape)
- `check_image_owner_scope(image: &Image, active: &str) -> GuardDecision`
  — pure decision. `owner == active` → Allow; `owner != active` →
  Block(FormSelectionMismatch{selected=owner, active}); `owner == None`
  → Block(FormSelectionMismatch{selected="", active}) — fail-safe
  deny when Glance omits the field.
- `emit_form_block_audit(reason, action_type, resource_kind,
  resource_id, active_project_id, audit_logger, actor_ctx,
  correlation_id)` — `GuardLayer::Fr4Form` mirror of
  `emit_origin_block_audit`. Stamps `resource_id` on the top-level
  event so the v1 fingerprint stays unique per row; passes
  `asserted_origin_project_id = None` (form layer has no origin to
  compare). correlation_id=0 (form not bound to a dispatch epoch);
  the future epoch-propagation cycle gets a real value.

worker.rs integration
- `handle_action` gains `audit_logger: Option<&AuditLogger>` and
  `actor_ctx: &Arc<RwLock<ActorContext>>`. `run_worker` clones both
  into the per-dispatch task closure.
- `Action::DeleteImage` branch: when `active_tenant` is `Some` and the
  pre-mutation GET succeeds, run `check_image_owner_scope`. On Block,
  emit the FR4 audit entry, send
  `AppEvent::CrossProjectBlocked { reason, action: "DeleteImage" }`,
  and return — never calling `delete_image`. Pre-GET failure or
  unscoped session falls through to the existing delete path so the
  user sees the underlying ApiError, not a silent block.

Tests (5 new RED → GREEN)
- `_check_image_owner_scope_match_allows`
- `_check_image_owner_scope_mismatch_blocks`
- `_check_image_owner_scope_missing_owner_fail_safe`
- `_emit_form_block_audit_writes_entry_with_fr4_form_layer`
  (TempDir + AuditLogger, asserts
   `details.guard_layer == "fr4_form"`,
   `result.failed == "cross_project_block:form_selection_mismatch"`,
   `resource_id == "img-99"`, `correlation_id == 17`)
- `_emit_form_block_audit_does_not_panic_when_logger_none`

Pure helpers cover the decision matrix; `handle_action` integration is
not unit-tested directly because `MockGlanceAdapter::get_image` returns
`NotFound` — would need a configurable mock to drive the cross-project
path through `handle_action`. Tracked as follow-up: `MockGlanceWithImage`
or behavior-override mock.

1464 → 1469 pass (+5 new), clippy clean, worker.rs fmt clean
(pre-existing Phase-11 diffs preserved).

Next: Phase 10 Step 17 — `src/ui/cross_project_toast.rs` (3 builders +
safe_display 60-char + ToastLevel::Warning).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- BL-P2-089 (Medium): Glance `UpdateImage` Action variant + FR4 guard.
  Step 16 descoped UpdateImage because `Action::UpdateImage` doesn't
  exist today; this BL adds the variant + worker handler + same FR4
  pre-mutation GET pattern as DeleteImage.
- BL-P2-090 (Low): `MockGlanceWithImage` configurable mock for the
  `handle_action(Action::DeleteImage)` integration test gap left by
  Step 16 (pure helpers cover the matrix, but the worker branch
  itself isn't unit-tested because the default mock returns
  NotFound).

IDs chosen 089/090 because memory tracks 087/088 as already
pre-allocated by main's BL-P2-086 follow-ups (cleanup / pre-flight).
Reconcile if main backlog.md diverges at PR-merge time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…facing builders)

User-visible toast builders for the three cross-project block scenarios.
Lives in `src/ui/cross_project_toast.rs`, registered via `src/ui/mod.rs`.

Surface
- `origin_mismatch_toast(origin, target) -> (String, ToastLevel::Error)`
  — FR2 worker block (dispatch origin ≠ active scope).
- `glance_owner_mismatch_toast(owner, active) -> (..., Error)` — FR4
  pre-mutation block specifically for Glance images (Step 16 partner).
- `form_mismatch_toast(field, selected, active) -> (..., Error)` —
  FR4 generic form-selection block carrying the offending field label.
- Internal `safe_display_60` char-count truncate (`…` suffix) so a
  pathological project/image/field name can't overrun the one-line
  toast region. Char-based for now; width-aware swap via
  `unicode-width` will land with BL-P2-050.

Plan adjustments vs code-plan.md Step 17
- Plan called for `ToastLevel::Warning`, but the enum has no Warning
  variant — Phase 7 Step 11c already routes
  `AppEvent::CrossProjectBlocked` through Error TTL (PermissionDenied
  parity). Aligned the test (`_is_error` not `_is_warning`).
- `safe_display` is tracked under BL-P2-050 (BL-P2-074 deferred it);
  introducing it project-wide is out of scope. Inlined the
  60-char truncate here with the same name so BL-P2-050 can replace
  the body without touching call sites.

Tests (5 RED → GREEN, single-pass single-file)
- `_origin_mismatch_toast_contains_both_project_names`
- `_glance_owner_mismatch_toast_contains_owner_name`
- `_form_mismatch_toast_contains_field_label`
- `_toast_level_is_error` (3 builders all Error)
- `_toast_respects_60char_truncate` (100-char input → 59 chars + `…`)

1469 → 1474 pass (+5 new), clippy clean.

Next: Step 18 (Phase 11) — background poll architectural pin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… pin

Inspection of `poll_server_status` / `poll_migration_progress` shows
both pollers send `AppEvent`s directly on `event_tx` and never dispatch
through `ActionSender`. The plan-time worry that background tasks
might emit mutation actions with a stale scope therefore does not
apply — the FR2 origin-stamping path covers only the user-initiated
mutation dispatch loop.

Doc additions (worker.rs)
- `poll_server_status` doc: declares the read-only contract and tells
  future maintainers to route any new mutation dispatch through
  `ActionSender::send` so the Step-9 central stamping handles origin
  automatically. Hand-built `DispatchedAction`s in poll bodies are
  forbidden.
- `poll_migration_progress` doc: same pin, cross-references the
  sibling.
- Module-level test-section note inside `worker::tests` summarizes
  why no runtime/signature-pin test was added: the pollers are
  multi-minute async loops not suited to unit invocation without
  elaborate mocking, and `async fn` doesn't coerce to a `fn` pointer
  so a compile-time signature guard would be artificial. The two
  function doc comments are the canonical guard.

Plan adjustments vs code-plan.md Step 18
- Plan listed two RED tests
  (`_emits_action_with_current_scope_stamp` /
  `_stale_action_blocked_after_scope_switch`) under the assumption
  that pollers call `ActionSender::send`. Re-reading the code shows
  they don't — those tests would assert a behavior that isn't even
  surfaced by the current implementation. Switched to doc-only
  arch pinning so a future refactor that wires `ActionSender` into
  the poll path automatically inherits the FR2 stamping covered by
  the existing 1474 tests.

No code-path changes — purely architectural documentation. 1474 pass,
clippy clean.

**Phase 11 feature-complete.** BL-P2-085 worker (FR2) + adapter (FR1) +
form (FR4) + UI (FR6) + background pin (FR2 scope) chains are all
wired. Ready for the PR-direct finishing pass: external review,
Phase-11-final-pass fmt, main sync, atomic security PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backlog hook auto-appended a file-edit line at the end of audit.md
after the BL-P2-089/090 registration commit (a2bce46). Splitting it
out as a separate chore so the Step 17/18 commits stay scoped to
their actual surface (toast / worker doc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s + DeleteImage docstring

Apply the 3 immediately-actionable items from the branch-full
cargo-review verdict (Correctness #2/#3/#5) before the PR-direct
finishing pass.

Correctness #3 — `module/common/scope_validator.rs`
- `validate_form_scope` now `debug_assert!(!active.is_empty())` on
  entry. Parity with `RefilterScope::strict` (cargo-review S-class
  follow-up fd60d83). An empty active combined with selections
  carrying `project_id == Some("")` would silent-allow cross-project
  rows; production callers must short-circuit to the unscoped path
  (worker FR2) before reaching this validator.

Correctness #5 — `worker.rs::check_image_owner_scope`
- Same `debug_assert!(!active.is_empty())` parity. The current
  production caller (`Action::DeleteImage` branch) already wraps the
  call in `active_tenant.as_deref()`, so empty active can only arrive
  if the token starts returning `project_id == Some("")`. Caught in
  dev builds; release builds rely on the upstream `from_parts`-style
  normalization that the worker dispatch path already applies.

Correctness #2 — `worker.rs::Action::DeleteImage` docstring
- Previous comment said "FR2 already handles the unscoped failsafe",
  which is inaccurate. Unstamped envelopes route through
  `check_dispatched_origin` → Allow (origin=None), and FR4 also skips
  because `active_tenant` is None. RBAC `can_perform` is the only
  gate; pre-auth routing is the App layer's responsibility.
- Also documented the pre-GET failure trade-off explicitly: 404 OK
  pass-through is intentional, but 401/403/5xx could route past FR4
  in principle. A future BL can fail-closed for auth/5xx while
  keeping 404 semantics.

Tests (2 new RED → GREEN, `#[should_panic]`)
- `_validate_form_scope_panics_on_empty_active`
- `_check_image_owner_scope_panics_on_empty_active`

1474 → 1476 pass (+2 new), clippy clean.

Next (Phase B): register BL-P2-091/092/093/094 follow-ups from the
cargo-review report — Glance FR1, cross-resource pre-mutation FR4,
actor_ctx.user_id cloud-switch sync, fingerprint v2 schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ew branch-full follow-ups

cargo-review branch-full (39 commits, base=main) verdict was NEEDS
DISCUSSION because 3 SECURITY + 2 MED findings touch the atomic
security promise itself. Phase A (b40f3af) reflected the 3 immediate
fixes; this commit registers the remaining 4 as follow-up BLs so the
PR body can reference concrete IDs.

- BL-P2-091 (Medium): Glance FR1 — `ScopedItem for Image` +
  `GlanceHttpAdapter::with_audit`. Closes the asymmetry that
  `FetchImages` currently leaks cross-project rows into the list UI
  while only `DeleteImage` is FR4-protected.
- BL-P2-092 (Medium): Cross-resource pre-mutation FR4 —
  AssociateFloatingIp / AttachVolume / DetachVolume etc., where the
  two resources can live in different projects. Adds
  `check_pair_scope` helper.
- BL-P2-093 (High): `actor_ctx.user_id` cloud-switch live sync.
  Current wire-startup fixation breaks audit attribution AND
  fingerprint v1 dedup across multi-cloud switches.
- BL-P2-094 (Low, v2-trigger): Fingerprint v2 schema with `|` escape
  or length-prefix. v1 is LOCKED but the unescaped delimiter can
  collide on customizable fields; v2 bump must include the fix.

PR body will name BL-P2-091/092/093/094 as deferred but documented
findings; BL-P2-093 (High) flagged for fast-follow after the BL-P2-085
PR merges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng fmt diffs

Apply `cargo fmt` across the worktree to clear the pre-existing fmt
diffs that earlier sub-cycles deferred to this single Phase-11 pass.
Every prior commit message that mentioned "touched files fmt clean
(pre-existing Phase-11 diffs preserved)" was banking on this commit.

Files affected (line-collapses / chain reformats / arg-list joins —
no semantic change):
- src/app.rs (`set_actor_ctx` arg list)
- src/context/action_channel.rs (4 spots — `recv` / `into_inner` /
  `send` / `try_channel` tuple)
- src/error.rs (assert! macro wrapping)
- src/infra/cross_project_audit.rs (assert chains)
- src/infra/rbac.rs (`scope_decision` arg list)
- src/main.rs (`ActionSender::new` call site)
- src/adapter/http/scope_refilter.rs (test fn arg-list)
- src/ui/cross_project_toast.rs (test bodies)
- src/worker.rs (assorted)

1476 pass (no count change — behavioral neutrality), clippy clean,
`cargo fmt --check` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…FetchPortBindingsForServer` (BL-P2-086)

BL-P2-086 merged `Action::FetchPortBindingsForServer { server_id }` for
the live-migrate stale-port-binding diagnosis (PR #83). Phase 5 Step 7
of BL-P2-085 removed the `_ => None` catch-all from `action_to_kind`
on purpose so every new Action variant has to be classified explicitly
— this is the trip-wire that caught the merge.

Classification: read-only Fetch* (no RBAC kind, no FR2 stamp). Lands
in the same arm as `Action::FetchPorts { .. }` / `FetchMigrationProgress`
/ etc. — all the other server-detail polling fetches.

1476 → 1492 pass (+16 from BL-P2-086 test suite), clippy clean,
`cargo fmt --check` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bluejayA bluejayA merged commit 0b53950 into main May 12, 2026
3 checks passed
@bluejayA bluejayA deleted the feature/bl-p2-085-cross-project-scoping branch May 12, 2026 04:37
bluejayA added a commit that referenced this pull request May 13, 2026
… refilter (#87)

BL-P2-085 follow-up. Closes the FR1+FR2+FR3+FR4 layered defense asymmetry that atomic security PR #85 deferred for Glance image-visibility semantics.

`ScopedItem` gains an `is_globally_accessible()` predicate (default false preserves Neutron/Nova/Cinder behavior). `Image` overrides to return true for `public`/`community`/`shared` visibility (positive allowlist; unknown values fail-safe to owner refilter). `GlanceHttpAdapter::with_audit` + `list_images` refilter wiring + `registry::new_http` consumes `audit.glance`.

Codex P1 fix included: visibility-aware short-circuit prevents non-admin users losing access to public Ubuntu/distro images. FR1 leak signal preserved: `visibility=private` AND `owner != active`.

cargo test = 1504 passed (1494 → +10). clippy clean. fmt clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant