Scope Drift UX Enhancement: Implementation Plan
Date: 2026-02-25
Status: Draft v2 (addresses review findings)
Depends on: Backend phases (1-3) can run in parallel with Gap 2 (remediation) since they touch different files (sections.ts vs new remediation.ts). Frontend phases (4-5) must merge AFTER Gap 2 frontend (Steps 8-10) since both modify EvidencePackViewer.tsx and FindingDetail.tsx.
Effort estimate: 1-2 days
Owner: TBD
Target: Week 2 (by Mar 11)
Problem Description
The scope_drift evaluator rule fires correctly when an identity or workload accumulates HAS_ROLE relationships beyond its baseline count. The Finding Detail page shows a text explanation:
"Identity has 5 role(s) now vs 2 at first observation. 3 role(s) added since baseline."
This tells the user that drift occurred but does not tell the "so what" story:
- WHAT drifted: Which specific roles were added? What are their names?
- WHEN it drifted: At which sync did each new role appear? Was it a single bulk grant or incremental creep?
- WHAT it now grants access to: Each new role grants permissions that APPLY_TO resources. What resources — at what sensitivity level — does the expanded scope now reach?
The evidence pack sections (authority_snapshot, temporal_context, blast_radius) contain raw data about the current state but don't present a before/after narrative. The EvidencePackViewer renders all sections identically as generic key-value tables. A security reviewer must manually navigate to Temporal Comparison, pick dates, and cross-reference roles to resources.
Architectural Analysis
Data Already Available
In the scope_drift rule's evidence_refs (stored in FindingDoc):
{
current_role_count: 5,
baseline_role_count: 2,
added_role_targets: ["sn-role-ap-write", "sn-role-ar-write", "sn-role-ledger-admin"],
baseline_version_date: "2025-11-27T00:00:00.000Z"
}
This is stored in MongoDB but never exposed through the API — the /api/v1/findings/:id endpoint does not include evidence_refs in its response.
In the evidence pack:
authority_snapshot.roles— current full role list with namesauthority_snapshot.execution_paths— current resources reachable via rolestemporal_context.version_countanddrift_event_countblast_radius.resource_listwith sensitivity per resource
In entity version history (/api/v1/entities/:id/versions):
- Full version snapshots at each sync point
- Each version includes
relationships[]with HAS_ROLE entries - Versions have
valid_attimestamps andsync_versionnumbers
In the entity diff API (/api/v1/entities/:id/diff?from=&to=):
relationships.addedandrelationships.removedwith full objectsexecution_paths.addedandexecution_paths.removedwith resource names, sensitivity, via_roles
Data Gaps
evidence_refsnot in API response: The finding detail endpoint stripsevidence_refs. Theadded_role_targetsarray is the critical missing link.- No per-version role diff in evidence pack: The
authority_snapshotshows current state but not the delta. There's no "scope_drift_detail" section. - No role-to-resource mapping for individual added roles: Blast radius shows all reachable resources but doesn't attribute which are reachable because of the newly added roles.
- Version history not fetched on FindingDetail page: The page fetches only the finding and evidence pack.
Approach: Enrich the Evidence Pack
Add a new scope_drift_detail section computed during buildEvidencePackContent() when the finding type is scope_drift. This follows the platform's design principles:
- Evidence-grade: Drift detail becomes part of the immutable, integrity-hashed evidence pack
- No new endpoints: The UI already fetches the evidence pack; a new section appears automatically
- Deterministic: Computed from version history already in
EvidenceBuildContext - Temporal: Each added role attributed to the version where it first appeared
Additionally, expose evidence_refs in the finding detail API response.
UX Design
Scope Drift Detail Panel
When finding type is scope_drift, the FindingDetail page renders a dedicated panel above the generic evidence pack sections, with three sub-sections:
A. Drift Summary Header
Compact summary card:
Baseline: 2 roles (observed 2025-11-27)
Current: 5 roles (as of 2026-02-25)
Drift: +3 roles added
Visual: two stacked horizontal bars (baseline in gray, current in blue) with the delta highlighted in amber.
B. Drift Timeline
Vertical timeline showing each version where roles changed:
[dot] Sync 2 — Feb 25, 2026
+ AP Write → AP/AR Ledger (restricted)
+ AR Write → AP/AR Ledger (restricted)
+ Ledger Admin → Financial Records API (restricted)
[dot] Baseline — Nov 27, 2025
Finance Read → Financial Records API (restricted)
Invoice View → Invoice Archive (confidential)
Each added role shows:
- Role name (resolved from entity batch lookup)
- Arrow to the resource(s) it grants access to
- Sensitivity badge (color-coded: restricted=red, confidential=amber, internal=gray)
Baseline roles shown as a collapsed group at the bottom.
C. Blast Radius Comparison (Before vs After)
Side-by-side comparison:
Before (baseline):
- 2 resources reachable
- 1 restricted, 1 confidential
After (current):
- 3 resources reachable
- 2 restricted, 1 confidential
- NEW: AP/AR Ledger (restricted) — via AP Write, AR Write, Ledger Admin
New resources highlighted with a green-bordered card and delta indicator.
Integration Points
- FindingDetail page: Check
finding.finding_type === "scope_drift"and renderScopeDriftDetailcomponent between Explanation and Evidence Pack. - EvidencePackViewer: Add
scope_drift_detailtoSECTION_ORDERbetweenauthority_snapshotandownership_timeline.
Type Definitions
New Types (in src/domain/evidence-packs/types.ts)
export interface ScopeDriftRoleEntry {
role_id: string;
role_name: string;
first_seen_version: number;
first_seen_at: string;
grants_access_to: Array<{
resource_id: string;
resource_name: string;
sensitivity: string;
business_domain: string;
actions: string[];
}>;
}
export interface ScopeDriftDetailSection {
baseline_role_count: number;
current_role_count: number;
added_role_count: number;
baseline_version_date: string;
baseline_roles: Array<{ role_id: string; role_name: string }>;
added_roles: ScopeDriftRoleEntry[];
blast_radius_before: {
total_resources: number;
by_sensitivity: Record<string, number>;
};
blast_radius_after: {
total_resources: number;
by_sensitivity: Record<string, number>;
};
new_resources: Array<{
resource_id: string;
resource_name: string;
sensitivity: string;
business_domain: string;
via_roles: string[];
}>;
}
Add scope_drift_detail?: ScopeDriftDetailSection to EvidencePackContent (optional, only present for scope_drift findings).
buildScopeDriftDetail Logic
- Return
undefinedif finding type is notscope_drift. - Extract
added_role_targetsandbaseline_version_datefromctx.finding.evidence_refs. - Walk
ctx.versionHistoryto find the earliest version where each added role appears — setfirst_seen_versionandfirst_seen_at. - Resolve role names from version history, not just current roleEntities.
ctx.roleEntitiesonly contains entities for CURRENT HAS_ROLE targets (seebuild-evidence-pack.tslines 43-72 — it readsentity.relationshipswhich is the current snapshot).- If a role was added and later removed, it will NOT be in
ctx.roleEntities. - Fix: In
buildScopeDriftDetail, collect ALL role IDs fromadded_role_targets. For any that are NOT inctx.roleEntities, fall back to extracting the role name from the version history itself (each version'srelationships[]entries contain thetarget_idand often adisplay_nameor the role node'ssource_id). - Alternative fix (preferred): Extend
build-evidence-pack.tsto also load role entities foradded_role_targetsfromctx.finding.evidence_refswhen the finding type isscope_drift. This is a small change (add to theroleIdscollection before the batch fetch) and ensures the evidence pack has complete role data.
- Compute per-role resource access by examining
ctx.entity.execution_pathsand filtering byvia_role_idsthat include the added role. - Compute baseline blast radius from the oldest version's execution_paths.
- Compute current blast radius from current entity's execution_paths.
- Identify "new resources" (in current but not baseline) and attribute to which added roles grant access.
Historical role loading (Step 2b addition to build-evidence-pack.ts):
// In build-evidence-pack.ts, after collecting roleIds from current relationships:
if (finding.finding_type === "scope_drift" && finding.evidence_refs?.added_role_targets) {
const addedTargets = finding.evidence_refs.added_role_targets as string[];
for (const id of addedTargets) {
if (!roleIds.includes(id)) roleIds.push(id);
}
}
This ensures ctx.roleEntities includes both current AND historically-added roles. If a role entity no longer exists in the DB (deleted), getEntitiesByIds will simply not return it, and the builder falls back to the role ID string.
Demo Data Requirements
Current Seed State
seed-demo-w1.ts already creates a compelling scope drift scenario:
- Sync 1 (baseline):
id-svc-financehas 2 roles:Finance Read,Invoice View - Sync 2 (full):
id-svc-financehas 5 roles: addsAP Write,AR Write,Ledger Admin - The 3 added roles grant write/admin access to
AP/AR Ledger(restricted) andFinancial Records API(restricted)
This is already compelling: a service principal that started with read-only finance access now has write and admin access to restricted financial resources.
Enhancement: Add Intermediate Sync for 3-Point Timeline
To show incremental role creep (the more concerning real-world pattern), add a third sync between baseline and current:
- Sync 1 (baseline, ~90 days ago): 2 roles — already exists
- Sync 1.5 (intermediate, ~30 days ago): 3 roles (add
AP Writeonly) — NEW - Sync 2 (current): 5 roles (add
AR Write,Ledger Admin) — already exists
This creates a 3-point timeline:
[Nov 27] Baseline: Finance Read, Invoice View
[Jan 25] +1 role: AP Write → AP/AR Ledger (restricted)
[Feb 25] +2 roles: AR Write → AP/AR Ledger (restricted)
Ledger Admin → Financial Records API (restricted)
Seed changes required:
- New
sync1_5Payloadwithwl-invoice-ruleandid-svc-financehaving 3 roles - Insert API call and poll between existing sync 1 and sync 2
- Use timestamp ~30 days ago
- Add temporal marker:
"AP Write role added by change request CR-4821"
Second Scope Drift Case
The seed already includes a second candidate: wl-ascribe-summarizer goes from 1 role (SQL Admin Reader) to 2 roles (adds SQL Clinical Reader). This is a lighter case showing the feature works for workloads with direct HAS_ROLE, and the clinical data access makes it a compelling healthcare scenario.
Implementation Steps
Phase 1: Backend — Expose evidence_refs in Finding Detail API
| Step | File | Description | Effort |
|---|---|---|---|
| 1a | src/api/routes/findings.ts | Add evidence_refs: finding.evidence_refs to GET /findings/:id response | 10 min |
| 1b | ui/src/api/api-types.ts | Add evidence_refs?: Record<string, unknown> to DetailedFinding | 5 min |
Phase 2: Backend — Add scope_drift_detail section to evidence pack
| Step | File | Description | Effort |
|---|---|---|---|
| 2a | src/domain/evidence-packs/types.ts | Define ScopeDriftRoleEntry, ScopeDriftDetailSection, add optional field to EvidencePackContent | 20 min |
| 2b | src/evidence/sections.ts | Implement buildScopeDriftDetail(ctx) — version history walk, role resolution, per-role resource attribution, baseline/current blast radius | 2-3 hr |
| 2c | src/evidence/markdown.ts | Add markdown rendering for scope_drift_detail section | 30 min |
Phase 3: Backend — Enrich scope_drift rule evidence_refs
Important: Do NOT add added_role_names to evidence_refs.
The finding change detection (evaluator/index.ts lines 287-304) compares evidence_refs via sorted JSON — excluding only sync_id. If display names are stored in evidence_refs, any entity rename (e.g., "Finance Read" → "Finance Reader") would trigger a "changed" finding, which triggers an evidence pack rebuild, even though the actual risk state is unchanged. This creates unnecessary churn.
Instead, role name resolution should happen only in the evidence pack builder (which already has ctx.roleEntities), not in the evaluator rule.
| Step | File | Description | Effort |
|---|---|---|---|
| 3a | src/evaluator/rules/scope-drift.ts | No change to evidence_refs. Keep added_role_targets (IDs only). Role name resolution happens downstream in the evidence pack builder, not in the rule. | 0 min |
Phase 4: Frontend — ScopeDriftDetail component
| Step | File | Description | Effort |
|---|---|---|---|
| 4a | ui/src/components/findings/ScopeDriftDetail.tsx (NEW) | Drift Summary header (baseline vs current bar), Drift Timeline (vertical timeline with role additions and resource access), Blast Radius Comparison (before/after) | 3-4 hr |
Reuse existing components:
SeverityBadgepattern for sensitivity badgesDeltaIndicatorfor "+3 roles" delta displayDiffSectionpattern from TemporalComparePage for "added" cards
Phase 5: Frontend — Integration
| Step | File | Description | Effort |
|---|---|---|---|
| 5a | ui/src/pages/FindingDetail.tsx | Check finding_type === "scope_drift", render ScopeDriftDetail between Explanation and Evidence Pack | 30 min |
| 5b | ui/src/components/findings/EvidencePackViewer.tsx | Add scope_drift_detail to SECTION_ORDER, add rendering branch in SectionContent | 30 min |
| 5c | ui/src/api/api-types.ts | Add scope_drift_detail to EvidencePackContent type | 5 min |
Phase 6: Seed Data Enhancement
| Step | File | Description | Effort |
|---|---|---|---|
| 6a | scripts/seed-demo-w1.ts | Add intermediate sync between sync 1 and 2 with AP Write only, temporal marker, ~30 days ago timestamp | 1-2 hr |
Phase 7: Tests
| Step | File | Description | Effort |
|---|---|---|---|
| 7a | test/evidence/sections.test.ts | Test buildScopeDriftDetail with version history showing role additions, verify returns undefined for non-scope_drift, verify per-role resource attribution | 1-2 hr |
| 7b | test/evaluator/rules/scope-drift.test.ts | Verify evidence_refs contains added_role_targets (IDs only, no display names). Confirm no added_role_names key exists. | 15 min |
Dependency Graph
Phase 1 (evidence_refs) ──── no dependencies
Phase 3 (rule enrichment) ── no dependencies
Phase 2 (evidence pack) ──── depends on types from Phase 2a
Phase 4 (UI component) ───── depends on types from Phase 2a
Phase 5 (UI integration) ─── depends on Phase 4
Phase 6 (seed data) ──────── no dependencies (parallel)
Phase 7 (tests) ───────────── depends on all above
Recommended: Phase 1 + 3 first (small, unblocks data), then Phase 2 (core logic), then Phase 4 + 5 (UI), then Phase 6 (demo), then Phase 7 (tests).
Risks
| Risk | Mitigation |
|---|---|
via_roles attribution: execution_paths[].via_roles contains role names (not IDs), while via_role_ids is optional. The scope drift rule tracks added_role_targets as IDs. | roleEntities in evidence build context provides the ID-to-name mapping. Extended to also load historical role targets from evidence_refs (see buildScopeDriftDetail Logic). |
Historical roles not in ctx.roleEntities: build-evidence-pack.ts only loads current HAS_ROLE targets. Roles that were added and later removed would be missing. | Fix: extend role ID collection in build-evidence-pack.ts to include evidence_refs.added_role_targets for scope_drift findings. Falls back to role ID string if entity no longer exists. |
| Evidence_refs churn from display names: Putting display names in evidence_refs would trigger finding change detection on entity renames. | Decision: do NOT store role names in evidence_refs. Keep added_role_targets (IDs only). Name resolution happens in evidence pack builder, not evaluator rule. |
Evidence pack integrity: Adding scope_drift_detail changes the content hash. | Expected — pack chaining via previous_pack_id preserves history. Section is optional so older packs remain valid. |
| Performance: Version history walk is bounded by existing 50-version limit. | One additional batch query for historical role entities (small — typically 1-5 IDs). |
| Backward compatibility: New key in EvidencePackContent. | Optional field. UI skips undefined sections. evidence_refs addition to API is additive. |
File collision with remediation plan: Both plans touch src/evidence/sections.ts and ui/src/components/findings/EvidencePackViewer.tsx. | Backend phases can run in parallel (remediation adds new file, scope drift modifies sections.ts). Frontend phases must merge sequentially — remediation first, then scope drift adds to the same components. |