# Requirements: QSP-40680 — Provide an option to get Scorecards API by appID

## Status
📋 Draft — awaiting approval

---

## Summary

The `scorecard-results` App Function currently requires a Backstage catalog ID (`assetID`) to look up a component's scorecard. Dynatrace app teams often only know their app's Dynatrace app identifier (`dynatrace.io/app-identifier` annotation on the Smartscape node), not the full Backstage catalog ID. This ticket adds `appID` as an alternative lookup key: when provided, the function resolves it to an `assetID` via a Smartscape query, then proceeds with the existing scorecard fetch. The existing `assetID`-based flow is unchanged.

---

## Acceptance Criteria

- [ ] AC-1: The API returns a `ScorecardResult` when a valid `assetID` and `version` are provided (existing behaviour fully unchanged).
- [ ] AC-2: The API returns a `ScorecardResult` when `appID` and `version` are provided, by resolving `appID` to `assetID` via Smartscape and then fetching from Grail.
- [ ] AC-3: When both `assetID` and `appID` are provided and the `assetID` lookup finds a scorecard, that result is returned (no Smartscape query is made).
- [ ] AC-4: When both `assetID` and `appID` are provided and the `assetID` lookup returns 404, the function falls back to the `appID` resolution path.
- [ ] AC-5: When `appID` is provided but Smartscape returns no matching component, the API returns `404` with a message naming the `appID`.
- [ ] AC-6: When neither `assetID` nor `appID` is provided, the API returns `404` with the message `'assetID or appID is required'`.
- [ ] AC-7: `scorecard-results-user-doc.md` documents the new `appID` field, the fallback behaviour, and the new required OAuth2 scope `storage:smartscape:read`.

---

## Key Decisions

| # | Decision | Rationale |
|---|---|---|
| D-1 | When both `assetID` and `appID` are provided, try `assetID` first; fall back to `appID` only if Grail returns 404 | Callers prefer the precise catalog ID; `appID` is a fallback for when only the annotation is known |
| D-2 | No format validation on `appID` | The app ID format is not constrained enough for a useful regex; garbage input results in a Smartscape "no match" 404 |
| D-3 | No caching of the `appID → assetID` lookup | `fetchWithCache` requires the caller to have `app-state` read/write permissions — an unwanted dependency |
| D-4 | Lifecycle filter `entity.lifecycle == "production"` always applied | Keeps scope narrow; can be relaxed in a follow-up if needed |
| D-5 | 404 on Smartscape "no match" only on the `appID` path | The `assetID` path already emits its own 404; the new message must name the `appID` to distinguish lookup failure from scorecard-not-found |

---

## Data Model

### Event Schema

No new events. The feature reads from two existing data sources:

| Source | Purpose |
|---|---|
| `smartscapeNodes "CATALOG*"` | Resolve `appID` annotation → `assetId` catalog string |
| `bizevents` (`scorecards.result`) | Existing scorecard result fetch — unchanged |

### TypeScript Interfaces

The `scorecardResults` context type is extended with one optional field (backward-compatible):

```typescript
// Before
ctx: { assetID?: string; version?: string }

// After
ctx: { assetID?: string; appID?: string; version?: string }
```

`ScorecardResult` response type is **unchanged**. The internal lookup result is a plain `string` (resolved `assetId`) — no new interface needed.

---

## UX / UI Specification

### Page Layout

Backend-only change. No UI components, pages, or routes are affected.

### Component Breakdown

N/A — backend App Function only.

### States

N/A — no UI states.

---

## API Contracts

### App Function Signatures

```typescript
// New exported helper — testable in isolation
export function buildAppIdLookupQuery(appID: string): string;

// New exported resolver — throws { status: 404 } when Smartscape returns no match
export async function resolveAssetIdFromAppId(appID: string): Promise<string>;

// Modified entry point
export default async function scorecardResults(
  ctx: { assetID?: string; appID?: string; version?: string }
): Promise<ScorecardResult>;
```

**`scorecardResults` control flow:**

```
1. If neither assetID nor appID → throw { status: 404, message: 'assetID or appID is required' }
2. version = ctx.version ?? 'summary'
3. If assetID provided:
   a. Validate with ASSET_ID_PATTERN → throw { status: 400 } if invalid
   b. Try fetchScorecardResult(assetID, version)
   c. If result found → return it (done)
   d. If throws { status: 404 } AND appID also provided → continue to step 4
   e. If throws { status: 404 } AND no appID → re-throw
4. If appID provided (sole input or fallback from step 3d):
   a. resolvedAssetId = await resolveAssetIdFromAppId(appID)
      → throws { status: 404, message: `No component found for appID: ${appID}` } if empty
   b. return fetchScorecardResult(resolvedAssetId, version)
```

### Cache Strategy

No caching. Both `resolveAssetIdFromAppId` and `fetchScorecardResult` always query fresh data.

---

## DQL Queries

### Query 1: appID → assetID Smartscape lookup

```dql
smartscapeNodes "CATALOG*"
| filter kind == "component"
| filter entity.lifecycle == "production"
| filter entity.type == "app"
| filter entity.annotations[`dynatrace.io/app-identifier`] == "${appID}"
| fields assetId = concat(kind,":",namespace,"/",name)
```

Validated via `dql_search`: ✅ (`mcp_dynatrace_verify_dql` — statement is valid)

**Notes:**
- `scanLimitGBytes: -1` does not apply to `smartscapeNodes` queries.
- Results bounded by passing `limit: 1` to `queryGrail` — only the first matching node is needed.
- Timeframe: `now - ONE_DAY` → `now` (consistent with `fetchCatalogComponentInfo` in `component-smartscape-node.ts`).

---

## Implementation Plan

### Consumer Impact Analysis

The `scorecardResults` signature change is **backward-compatible** (`appID` is optional). No existing caller passes `appID`.

| Interface / Type | Consumer file | Required change |
|---|---|---|
| `scorecardResults` ctx type | `src/api/tests/scorecard-results.function.spec.ts` | Add new test cases only; all existing tests remain valid |
| `scorecardResults` HTTP API | `src/api/scorecard-results-user-doc.md` | Document `appID`, fallback behaviour, new scope |
| `ScorecardResult` (response) | All consumers | No change — response shape is unchanged |

---

### Step 1: Add `buildAppIdLookupQuery` and `resolveAssetIdFromAppId`

- **File**: `src/api/scorecard-results.function.ts`
- **Changes**:
  - Extend the `api-constants` import to also include `ONE_DAY`.
  - Add `export function buildAppIdLookupQuery(appID: string): string` — returns the Smartscape DQL string with `appID` interpolated.
  - Add `export async function resolveAssetIdFromAppId(appID: string): Promise<string>` — calls `queryGrail(query, now - ONE_DAY, now, randomUUID(), 1)`, extracts `record['assetId'] as string` from the first result, throws `{ status: 404, message: \`No component found for appID: ${appID}\` }` when the result is empty.
- **Verification**: `npm run typecheck` passes; existing unit tests pass unchanged.

### Step 2: Update `scorecardResults` default export

- **File**: `src/api/scorecard-results.function.ts`
- **Changes**:
  - Extend `ctx` type to `{ assetID?: string; appID?: string; version?: string }`.
  - Replace the existing guard with the new control flow (see API Contracts).
- **Verification**: `npm run typecheck` passes; all existing tests pass.

### Step 3: Extend the unit test suite

- **File**: `src/api/tests/scorecard-results.function.spec.ts`
- **Changes**: Add test cases listed in Unit Test Plan. Existing tests are not modified.
- **Verification**: `npm run test:app -- --testPathPattern="scorecard-results.function.spec"` all pass; coverage ≥90% on the modified source file.

### Step 4: Update `scorecard-results-user-doc.md`

- **File**: `src/api/scorecard-results-user-doc.md`
- **Changes**:
  - Add `appID` row to the request body field table (optional string, no format constraint, mutually usable with `assetID`).
  - Add a "Lookup precedence" sub-section: `assetID` tried first; `appID` is fallback; neither → 404.
  - Add `storage:smartscape:read` to the required OAuth2 scopes table (needed when `appID` is used).
  - Add an HTTP example using `appID` only.
- **Verification**: All scope names and field descriptions are accurate.

---

## Unit Test Plan

### `src/api/tests/scorecard-results.function.spec.ts`

**`buildAppIdLookupQuery`**
- Includes filter `entity.annotations[\`dynatrace.io/app-identifier\`] == "<appID>"`
- Includes filter `entity.lifecycle == "production"`
- Includes filter `kind == "component"`
- Includes filter `entity.type == "app"`
- Includes `fields assetId = concat(kind,":",namespace,"/",name)`

**`resolveAssetIdFromAppId`**
- Returns the `assetId` string from the first Smartscape record when a match is found
- Throws `{ status: 404 }` containing the `appID` value when Smartscape returns an empty array

**`scorecardResults` — `appID`-only path**
- Resolves `appID` via Smartscape and returns `ScorecardResult` when result is found
- Throws 404 (propagated from `resolveAssetIdFromAppId`) when no Smartscape match exists

**`scorecardResults` — both `assetID` and `appID` provided**
- Returns result from `assetID` path and does not call Smartscape when `assetID` lookup succeeds
- Falls back to `appID` Smartscape lookup when `assetID` lookup returns 404
- Does NOT fall back to `appID` when `assetID` has an invalid format (400 is re-thrown)

**`scorecardResults` — neither provided**
- Throws `{ status: 404, message: 'assetID or appID is required' }` when `ctx` is `{}`

---

## Edge Cases & Error Handling

| Scenario | Behaviour |
|---|---|
| `appID` provided, Smartscape returns no match | `{ status: 404, message: 'No component found for appID: <appID>' }` |
| Both provided, `assetID` finds no scorecard, `appID` also finds no Smartscape match | 404 from Smartscape lookup |
| Both provided, `assetID` has invalid format | 400 from `ASSET_ID_PATTERN` — `appID` is **not** tried |
| `appID` resolves to `assetID` but no scorecard exists in Grail | Existing 404: `No scorecard result found for <assetId>:<version>` |
| Neither `assetID` nor `appID` | `{ status: 404, message: 'assetID or appID is required' }` |
| `appID` is empty string `""` | Treated as falsy — same as not provided; results in "neither provided" 404 |
| Smartscape returns multiple matches for the same `appID` | First result is used; no disambiguation attempted |

---

## Out of Scope

- **Lifecycle filter parameterisation**: Only `production` is matched. Supporting other lifecycle values is a separate ticket.
- **`appID` format validation**: No regex; garbage input results in a Smartscape "no match" 404.
- **Caching the `appID → assetID` lookup**: Excluded to avoid requiring callers to hold `app-state` permissions.
- **UI changes**: No pages, components, or routes are affected.
- **Multiple Smartscape matches for one `appID`**: First result wins; disambiguation is out of scope.

---

## Refinement Log

### Step 1 — Initial context read & questions

**Code investigation findings:**
- `src/api/scorecard-results.function.ts` (131 lines): `scorecardResults(ctx)` takes `{ assetID?, version? }`. Validation: `ASSET_ID_PATTERN`. Queries `scorecards.result` BizEvents.
- `src/utils/api-constants.ts`: `ASSET_ID_PATTERN` defined. No `APP_ID_PATTERN`.
- `src/api/catalog/component-smartscape-node.ts`: Existing Smartscape lookup pattern — reference for new query structure.
- `src/api/scorecard-results-user-doc.md`: Public HTTP API consumer guide — needs update.
- `src/api/tests/scorecard-results.function.spec.ts`: 380-line spec — new tests to be added.
- `component/testing-utils/grail-mock-util.ts`, `component/tests/components/scorecardsDataContextProvider.spec.tsx`: Use mock response only, not the function signature.

**Questions asked:** 7 questions covering precedence, error handling, lifecycle filter, validation, caching, doc update, and scopes.

### Step 2 — Answers received (2026-04-02)

1. `assetID` first; fall back to `appID` if `assetID` yields no result.
2. Yes, 404 — but only on the `appID` path.
3. Keep `entity.lifecycle == "production"` for now.
4. No validation at this time.
5. No caching — avoids `app-state` permission dependency.
6. Yes, update the user doc.
7. New scope required: `storage:smartscape:read`.

### Step 3 — DQL validation

DQL query for `appID → assetId` lookup validated via `mcp_dynatrace_verify_dql` — syntactically valid. Field `entity.annotations[\`dynatrace.io/app-identifier\`]` confirmed by context.md DQL.
