# Link-Not-Merge Linksets API (v1) Status: stable; frozen 2025-11-17 per LNM v1 spec. ## Intent - Provide fact-only advisory linkset retrieval for Policy Engine, graph overlays, and console clients. - Preserve provenance and tenant isolation; results are deterministically ordered and stable for identical queries. - Surface conflicts between observations without resolving them (Link-Not-Merge philosophy). ## Endpoints ### List Linksets - Method: `GET` - Path: `/v1/lnm/linksets` ### Get Linkset by ID - Method: `GET` - Path: `/v1/lnm/linksets/{advisoryId}` ### Search Linksets - Method: `POST` - Path: `/v1/lnm/linksets/search` ## Headers | Header | Required | Description | |--------|----------|-------------| | `X-Stella-Tenant` | Yes | Tenant identifier for multi-tenant isolation. | | `X-Stella-Request-Id` | No | Optional correlation ID for distributed tracing. | ## Query Parameters (GET) | Parameter | Type | Description | |-----------|------|-------------| | `purl` | string[] | Filter by Package URLs (repeatable). | | `cpe` | string | Filter by CPE identifier. | | `cve` | string | Filter by CVE identifier. | | `ghsa` | string | Filter by GHSA identifier. | | `advisoryId` | string | Filter by advisory ID. | | `source` | string | Filter by upstream source. | | `severityMin` | float | Minimum severity score. | | `severityMax` | float | Maximum severity score. | | `publishedSince` | datetime | Published after this timestamp. | | `modifiedSince` | datetime | Modified after this timestamp. | | `includeConflicts` | boolean | Include conflict details (default: true). | | `includeObservations` | boolean | Include observation IDs (default: false). | | `page` | integer | Page number (default: 1). | | `pageSize` | integer | Items per page (default: 50, max: 200). | | `sort` | string | Sort order (see sorting section). | ## Request Example (Search) ```json { "purl": ["pkg:npm/lodash@4.17.20"], "includeConflicts": true, "includeObservations": true, "pageSize": 10 } ``` ## Response (200) ```json { "items": [ { "advisoryId": "CVE-2021-23337", "source": "nvd", "purl": ["pkg:npm/lodash@4.17.20"], "cpe": ["cpe:2.3:a:lodash:lodash:4.17.20:*:*:*:*:node.js:*:*"], "summary": "Lodash versions prior to 4.17.21 are vulnerable to Command Injection via the template function.", "publishedAt": "2021-02-15T13:15:00Z", "modifiedAt": "2024-08-04T19:16:00Z", "severity": "high", "status": "affected", "provenance": { "ingestedAt": "2025-11-20T10:30:00Z", "connectorId": "nvd-osv-connector", "evidenceHash": "sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", "dsseEnvelopeHash": null }, "conflicts": [ { "field": "severity", "reason": "severity-mismatch", "observedValue": "critical", "observedAt": "2025-11-18T08:00:00Z", "evidenceHash": "sha256:f6e5d4c3b2a1098765432109876543210fedcba0987654321fedcba098765432" } ], "timeline": [ { "event": "observed", "at": "2025-11-15T10:00:00Z", "evidenceHash": "sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" }, { "event": "conflict-detected", "at": "2025-11-18T08:00:00Z", "evidenceHash": "sha256:f6e5d4c3b2a1098765432109876543210fedcba0987654321fedcba098765432" } ], "normalized": { "aliases": ["CVE-2021-23337", "GHSA-35jh-r3h4-6jhm"], "purl": ["pkg:npm/lodash@4.17.20"], "cpe": ["cpe:2.3:a:lodash:lodash:4.17.20:*:*:*:*:node.js:*:*"], "versions": ["4.17.20"], "ranges": [ { "type": "SEMVER", "events": [ {"introduced": "0.0.0"}, {"fixed": "4.17.21"} ] } ], "severities": [ {"type": "CVSS_V3", "score": 7.2} ] }, "cached": false, "remarks": [], "observations": ["obs:nvd:CVE-2021-23337:2025-11-15", "obs:github:GHSA-35jh-r3h4-6jhm:2025-11-18"] } ], "page": 1, "pageSize": 10, "total": 1 } ``` ## Response (Air-gapped deployment) When deployed in air-gapped mode, responses include freshness metadata: ```json { "items": [ { "advisoryId": "CVE-2021-23337", "source": "nvd", "purl": ["pkg:npm/lodash@4.17.20"], "cpe": [], "provenance": { "ingestedAt": "2025-11-20T10:30:00Z", "connectorId": "offline-bundle-importer", "evidenceHash": "sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" }, "conflicts": [], "cached": true, "freshness": { "staleness": { "lastRefreshedAt": "2025-11-20T10:30:00Z", "ageSeconds": 86400, "isStale": false, "thresholdSeconds": 172800, "status": "fresh" }, "bundleProvenance": { "bundleId": "offline-2025-11-20", "bundleVersion": "1.0.0", "sourceId": "nvd-mirror", "importedAt": "2025-11-20T10:30:00Z", "contentHash": "sha256:bundle-hash-here", "signatureStatus": "verified", "signatureKeyId": "key:stellaops:offline-signing:2025", "isAirGapped": true }, "computedAt": "2025-11-21T10:30:00Z" } } ], "page": 1, "pageSize": 10, "total": 1 } ``` ## Errors | Status | Code | Description | |--------|------|-------------| | 400 | `ERR_VALIDATION_FAILED` | Invalid query parameters or request body. | | 400 | `ERR_PAGE_SIZE_EXCEEDED` | Page size exceeds maximum of 200. | | 401 | `ERR_UNAUTHORIZED` | Missing or invalid authentication. | | 403 | `ERR_FORBIDDEN` | Tenant access denied. | | 404 | `ERR_RESOURCE_NOT_FOUND` | Linkset not found (for GET by ID). | | 429 | `ERR_RATE_LIMITED` | Too many requests; check Retry-After header. | ### Error Response Example ```json { "type": "https://stellaops.io/errors/validation-failed", "title": "Validation Failed", "status": 400, "detail": "The 'pageSize' parameter exceeds the maximum allowed value of 200.", "instance": "/v1/lnm/linksets", "traceId": "trace-id-abc123", "error": { "code": "ERR_PAGE_SIZE_EXCEEDED", "message": "Page size must be between 1 and 200.", "target": "pageSize", "metadata": { "provided": 500, "maximum": 200 } } } ``` ## Sorting Available sort options: - `modifiedAt desc` (default) - `modifiedAt asc` - `publishedAt desc` - `publishedAt asc` - `severity desc` - `severity asc` - `source` - `advisoryId` Tie-breaking: when primary sort values are equal, results are ordered by `advisoryId asc`, then `source asc`. ## Determinism & Caching - All results are deterministically ordered based on sort parameters. - Timestamps are UTC ISO-8601 format. - Hashes are lowercase hex with algorithm prefix (e.g., `sha256:`). - Cache key includes: `tenant|filters|sort|page|pageSize`. - Cache headers: `X-Stella-Cache-Hit`, `X-Stella-Cache-Key`. ## Notes - Linksets represent the current aggregate state of all observations for an advisory. - Conflicts are surfaced but not resolved; consumers must implement their own conflict resolution strategy. - The `normalized` field contains processed data suitable for version matching and range evaluation. - Observation IDs in the `observations` array can be used to fetch raw observation details via the observations API. ## Changelog - 2025-12-06: Added curated examples with conflict and air-gap scenarios (CONCELIER-WEB-OAS-62-001). - 2025-11-17: LNM v1 specification frozen.