# Requirements: QSP-39749 — Not version-specific signals should consider summary version

## Status
✅ Approved

---

## Summary

When `generateScorecardResults` is triggered for a specific release version (e.g., `1.54.5`), versionless signals (`versionSpecific: false` — Code Coverage, Code Quality, Code Security, Snyk/Vulnerability Remediation) always return score 0 in the generated `scorecards.result` event. This is because `buildSignalEventsQuery` applies `asset.version == "${assetVersion}"` to **all** signals, including those that are not version-specific. Versionless signals should never be filtered by version — the `dedup signal.id, sort:{timestamp desc}` clause is sufficient to pick the most recent result regardless of which version it was stored under. The fix is to split signal IDs by `versionSpecific` and omit the version filter entirely for the versionless group.

---

## Acceptance Criteria

- [ ] AC-1: When `generateScorecardResults` is called with a specific semver `assetVersion` (e.g., `1.54.5`), versionless signals (`versionSpecific: false`) are fetched **without any `asset.version` filter** — the latest result is selected via `dedup signal.id, sort:{timestamp desc}`.
- [ ] AC-2: Version-specific signals (`versionSpecific: true`) continue to be fetched using `asset.version == "${assetVersion}"` regardless of which version is specified.
- [ ] AC-3: The fix uses a single DQL query (no extra Grail round-trip) by using an OR condition when both versioned and versionless signal IDs are present.
- [ ] AC-4: All existing and new unit tests pass with ≥90% coverage.

---

## Key Decisions

| # | Decision | Rationale |
|---|---|---|
| D-1 | Modify `buildSignalEventsQuery` to accept `versionedSignalIds: string[]` and `versionlessSignalIds: string[]` instead of a single `signalIds: string[]` | Keeps all signal-query DQL logic in one place. Keeps the function pure (no domain type knowledge). Clear split of responsibility. |
| D-2 | Single OR-based DQL query when both groups have IDs | Avoids an extra Grail round-trip. The `dedup signal.id` clause collapses to at most one result per signal. |
| D-3 | Versionless signals never get an `asset.version` filter — in all cases (`assetVersion` semver or `"summary"`) | Versionless signals are not tied to any deployment version. `dedup signal.id, sort:{timestamp desc}` always picks the most recent result regardless of what version it was stored under. |
| D-4 | Splitting happens in the caller (`generate-scorecards-result-event.function.ts`) using `matchingSignalDefs` | `matchingSignalDefs` is already available at the call site and carries the `versionSpecific` flag. `buildSignalEventsQuery` stays a pure string builder. |
| D-5 | The `maxResults` argument to `queryGrail` remains `matchingSignalDefs.length` (total signal count) | The `dedup signal.id` in the DQL bounds results to ≤ one per signal. Total count stays the same. |

---

## Data Model

### Signal version filter rule

| Signal type | `versionSpecific` | Version filter in DQL |
|---|---|---|
| Versioned (acceptance-testing, system-integration-testing, unit-integration-testing, app-manifest-scan) | `true` | `asset.version == "${assetVersion}"` |
| Versionless (app-static-code-coverage, app-static-code-quality-rating, app-static-code-security-rating, remediation-compliance) | `false` | **None** — `dedup signal.id, sort:{timestamp desc}` picks the latest |

### No interface changes

No TypeScript interfaces are added or modified. `SignalDefinition.versionSpecific: boolean` (already present in `src/api/models/scorecard-result.model.ts`) is the source of truth for the split. `buildSignalEventsQuery`'s signature changes (string arrays only — no new types introduced).

---

## UX / UI Specification

This is a backend-only bug fix. No UI changes.

### States

Not applicable (backend function).

---

## API Contracts

### Updated function signature: `buildSignalEventsQuery`

```typescript
// Before (src/api/signals/signal-event.ts)
export function buildSignalEventsQuery(
  assetId: string,
  assetVersion: string,
  signalIds: string[],
): string

// After
export function buildSignalEventsQuery(
  assetId: string,
  assetVersion: string,
  versionedSignalIds: string[],
  versionlessSignalIds: string[],
): string
```

**Generated DQL — case: both groups non-empty (regardless of `assetVersion` value):**
```dql
fetch bizevents, scanLimitGBytes: -1
    | filter event.category == "scorecards.signal" and not isNull(signal.id)
    | filter asset.id == "<assetId>"
    | filter (in(signal.id, array("acceptance-testing", ...versioned...)) AND asset.version == "1.54.5")
         OR  (in(signal.id, array("remediation-compliance", ...versionless...)))
    | dedup signal.id, sort:{timestamp desc}
    | fieldsRemove dtOpenpipelineSource, dtOpenpipelinePipelines
```

**Generated DQL — case: only versioned signals present:**
```dql
fetch bizevents, scanLimitGBytes: -1
    | filter event.category == "scorecards.signal" and not isNull(signal.id)
    | filter asset.id == "<assetId>"
    | filter asset.version == "1.54.5"
    | filter in(signal.id, array(...versioned signal ids...))
    | dedup signal.id, sort:{timestamp desc}
    | fieldsRemove dtOpenpipelineSource, dtOpenpipelinePipelines
```

**Generated DQL — case: only versionless signals present:**
```dql
fetch bizevents, scanLimitGBytes: -1
    | filter event.category == "scorecards.signal" and not isNull(signal.id)
    | filter asset.id == "<assetId>"
    | filter in(signal.id, array(...versionless signal ids...))
    | dedup signal.id, sort:{timestamp desc}
    | fieldsRemove dtOpenpipelineSource, dtOpenpipelinePipelines
```

### Updated call site: `generate-scorecards-result-event.function.ts`

```typescript
// Before
const signalEventsResult = await queryGrail(
    buildSignalEventsQuery(payload.assetId, payload.assetVersion, matchingSignalIds),
    ...
    matchingSignalIds.length,
);

// After
const versionedSignalIds = matchingSignalDefs.filter((d) => d.versionSpecific).map((d) => d.id);
const versionlessSignalIds = matchingSignalDefs.filter((d) => !d.versionSpecific).map((d) => d.id);
const signalEventsResult = await queryGrail(
    buildSignalEventsQuery(payload.assetId, payload.assetVersion, versionedSignalIds, versionlessSignalIds),
    ...
    matchingSignalDefs.length,
);
```

### Cache Strategy

Not applicable — no caching in this function path.

---

## DQL Queries

### Query: signal events with version-aware filter

The new DQL generated by `buildSignalEventsQuery` when `assetVersion == "1.54.5"` and both groups have IDs:

```dql
fetch bizevents, scanLimitGBytes: -1
    | filter event.category == "scorecards.signal" and not isNull(signal.id)
    | filter asset.id == "component:dt-platform/databases"
    | filter (in(signal.id, array("acceptance-testing", "system-integration-testing", "unit-integration-testing", "app-manifest-scan")) AND asset.version == "1.54.5")
         OR  (in(signal.id, array("app-static-code-coverage", "app-static-code-security-rating", "app-static-code-quality-rating", "remediation-compliance")))
    | dedup signal.id, sort:{timestamp desc}
    | fieldsRemove dtOpenpipelineSource, dtOpenpipelinePipelines
```

Validated: ✅ (DQL OR with one version-filtered group and one unfiltered group is valid; `dedup` with `sort` is the existing validated pattern)

---

## Implementation Plan

### Consumer Impact Analysis

| Symbol | Consumer file | Required change |
|---|---|---|
| `buildSignalEventsQuery(assetId, assetVersion, signalIds)` | `src/api/generate-scorecards-result-event.function.ts` | Update call site: split `matchingSignalDefs` into `versionedSignalIds` / `versionlessSignalIds` |
| `buildSignalEventsQuery(assetId, assetVersion, signalIds)` | `src/api/signals/signal-event.spec.ts` | Update all call sites to new 4-arg signature |
| `buildSignalEventsQuery` (import) | `src/api/tests/generate-scorecards-result-event.function.spec.ts` | Update `setupHappyPathMocks` and snapshot test to reflect versioned/versionless split |

No frontend files, models, or other API functions are affected.

---

### Step 1: Modify `buildSignalEventsQuery` in `signal-event.ts`

- **File**: `src/api/signals/signal-event.ts`
- **Changes**:
  - Change signature from `(assetId, assetVersion, signalIds: string[])` to `(assetId, assetVersion, versionedSignalIds: string[], versionlessSignalIds: string[])`.
  - When only versioned IDs exist: emit `asset.version == "${assetVersion}" AND in(signal.id, ...)` (same shape as today).
  - When only versionless IDs exist: emit `in(signal.id, ...)` — no version filter.
  - When both groups are non-empty: emit OR condition `(in(signal.id, ...versioned...) AND asset.version == "${assetVersion}") OR (in(signal.id, ...versionless...))` — versionless side has no version filter.
  - The `assetVersion` parameter is still needed for the versioned group and for the existing `SEMANTIC_VERSIONING_REGEX` / `ASSET_ID_PATTERN` validation guards.
- **Verification**: `npm run typecheck:app` exits 0; `npm run test:app -- --testPathPattern="signal-event"` passes.

---

### Step 2: Update call site in `generate-scorecards-result-event.function.ts`

- **File**: `src/api/generate-scorecards-result-event.function.ts`
- **Changes**:
  - After computing `matchingSignalDefs`, derive `versionedSignalIds` and `versionlessSignalIds` by filtering on `d.versionSpecific`.
  - Pass both arrays to `buildSignalEventsQuery` instead of the flat `matchingSignalIds`.
  - Replace `matchingSignalIds.length` with `matchingSignalDefs.length` for `maxResults`.
- **Verification**: `npm run typecheck:app` exits 0; lint clean.

---

### Step 3: Update unit tests

- **File**: `src/api/signals/signal-event.spec.ts`
  - Update all `buildSignalEventsQuery` calls to 4-arg form.
  - Add new tests:
    - When both groups non-empty: OR condition appears; versioned side has version filter, versionless side has no version filter.
    - When only versioned IDs: no OR, uses `assetVersion`.
    - When only versionless IDs: no OR, no version filter — only `in(signal.id, ...)`.
    - DQL injection escaping still works for both ID arrays.
- **File**: `src/api/tests/generate-scorecards-result-event.function.spec.ts`
  - Update `setupHappyPathMocks` to mock the new single-query call with versioned+versionless signals combined in the response (no change to mock call count — still 3).
  - Update the snapshot test so versionless signals carry actual scores (from the mock) rather than 0.
  - Add a new test: "versionless signals are fetched without a version filter when assetVersion is a semver" — assert the DQL query passed to `queryGrail` does NOT contain `asset.version == "summary"` for the versionless signal group, and contains the OR structure.
- **Verification**: `npm run test:app -- --testPathPattern="signal-event|generate-scorecards-result-event"` all pass with ≥90% coverage.

---

## Unit Test Plan

### `src/api/signals/signal-event.spec.ts`

- `buildSignalEventsQuery` — existing tests updated to 4-arg signature
- `buildSignalEventsQuery` — OR filter appears when both groups are non-empty; versioned side has `asset.version`, versionless side has no version clause
- `buildSignalEventsQuery` — single version-filtered query when only versioned signals present
- `buildSignalEventsQuery` — no version filter at all when only versionless signals present
- `buildSignalEventsQuery` — DQL injection escape works for both ID arrays

### `src/api/tests/generate-scorecards-result-event.function.spec.ts`

- Existing call count test (`3 queryGrail calls`) still holds (single unified query)
- New test: the signal query argument to `queryGrail` contains no `asset.version` filter for the versionless signal group when `assetVersion == "1.0.0"`
- Update snapshot test: versionless signals now carry the scores returned by the mock (not 0)

---

## Edge Cases & Error Handling

| Edge case | Handling |
|---|---|
| All signals in the scorecard definition are versionless | Query uses only `in(signal.id, ...)` — no version filter at all |
| All signals in the scorecard definition are versioned | Query uses a single `asset.version == "${assetVersion}"` filter — no OR, no change in structure |
| `assetVersion == "summary"` | Versioned signals still get `asset.version == "summary"`. Versionless signals still get no version filter. OR condition emitted if both groups are non-empty. |
| No signal IDs in either group | `buildSignalEventsQuery` is called with two empty arrays — the `in(signal.id, array())` still builds a valid (always-false) filter, consistent with the existing pattern |
| New signal definition added with `versionSpecific: false` | Automatically routed to the versionless (no-filter) group at the call site; no code change needed |

---

## Out of Scope

- **QSP-39737 batch refactor**: This ticket fixes correctness; the performance refactor (single batch Grail call for all assets) is a separate concern.
- **Frontend display changes**: Scores are now correct in the ingested `scorecards.result` event; the frontend reads these without change.
- **`scorecards.result` events already in Grail with wrong versionless scores**: Historical events are not back-filled. Only newly generated events will be correct. Re-triggering is outside scope.
- **`scorecard-results.function.ts`**: The query-side (serving results to the frontend) is unaffected — it reads `scorecards.result` events, not signal events.
- **`buildScorecardResultQuery`**: No changes needed.

