Stella Ops Triage API Contract v1
This is the v1 HTTP contract for the Stella Ops triage surface: the findings table, per-case narrative and evidence, signed triage decisions, snapshots and Smart-Diff, evidence-bundle export, and gating explainability. It is written for UI, CLI, and integration developers building on top of Stella Ops triage. For the evidence-export endpoints in depth, see the Triage Evidence Export API Reference.
Base path: /api/v1/triage
This contract is served by scanner.webservice (or a dedicated triage facade that reads scanner-owned tables). All risk/lattice outputs originate from scanner.webservice.
Key requirements:
- Deterministic outputs (policyId + policyVersion + inputsHash).
- Proof-linking (chips reference evidenceIds).
concelierandexcititorpreserve prune source: API surfaces source chains viasourceRefs.
0. Conventions
0.1 Identifiers
caseId==findingId(UUID). A case is a finding scoped to an asset/environment.- The canonical
findingIdis the bare UUID returned unchanged in an/inbox?artifactDigest=...path’sfindingIdsarray. An artifact digest is a multi-row inbox filter, not a finding identity. - Matcher-derived tokens returned by Findings read models are not aliases for Scanner Triage UUIDs. Consumers must not send those tokens to this API.
- Hashes are hex strings.
0.2 Caching
- GET endpoints SHOULD return
ETag. - Clients SHOULD send
If-None-Match.
0.3 Errors
Standard error envelope:
{
"error": {
"code": "string",
"message": "string",
"details": { "any": "json" },
"traceId": "string"
}
}
Common codes:
not_foundvalidation_errorconflictunauthorizedforbiddenrate_limited
1. Findings Table
1.1 List findings
GET /findings
Query params:
showMuted(bool, default false)showHidden(bool, default false) — include gated findings in resultsgatingReason(optional, enum) — filter by specific gating reasonlane(optional, enum)search(optional string; searches asset, purl, cveId)page(int, default 1)pageSize(int, default 50; max 200)sort(optional:updatedAt,score,lane)order(optional:asc|desc)
Response 200:
{
"page": 1,
"pageSize": 50,
"total": 12345,
"actionableCount": 847,
"mutedCounts": { "reach": 1904, "vex": 513, "compensated": 18 },
"gatedBuckets": {
"unreachableCount": 1904,
"policyDismissedCount": 234,
"backportedCount": 456,
"vexNotAffectedCount": 513,
"supersededCount": 12,
"userMutedCount": 18,
"totalHiddenCount": 3137
},
"rows": [
{
"id": "uuid",
"lane": "BLOCKED",
"verdict": "BLOCK",
"score": 87,
"reachable": "YES",
"vex": "affected",
"exploit": "YES",
"asset": "prod/api-gateway:1.2.3",
"updatedAt": "2025-12-16T01:02:03Z",
"gatingReason": null,
"isHiddenByDefault": false
}
]
}
2. Case Narrative
2.1 Get case header
GET /cases/{caseId}
Response 200:
{
"id": "uuid",
"verdict": "BLOCK",
"lane": "BLOCKED",
"score": 87,
"policyId": "prod-strict",
"policyVersion": "2025.12.14",
"inputsHash": "hex",
"why": "Reachable path observed; exploit signal present; prod-strict blocks.",
"chips": [
{ "key": "reachability", "label": "Reachability", "value": "Reachable (92%)", "evidenceIds": ["uuid"] },
{ "key": "vex", "label": "VEX", "value": "affected", "evidenceIds": ["uuid"] },
{ "key": "gate", "label": "Gate", "value": "BLOCKED by prod-strict", "evidenceIds": ["uuid"] }
],
"sourceRefs": [
{
"domain": "concelier",
"kind": "cve_record",
"ref": "concelier:osv:...",
"pruned": false
},
{
"domain": "excititor",
"kind": "effective_vex",
"ref": "excititor:openvex:...",
"pruned": false
}
],
"updatedAt": "2025-12-16T01:02:03Z"
}
Notes:
sourceRefsprovides preserved provenance chains (including pruned markers when applicable).
3. Evidence
3.1 List evidence for case
GET /cases/{caseId}/evidence
Response 200:
{
"caseId": "uuid",
"items": [
{
"id": "uuid",
"type": "VEX_DOC",
"title": "Vendor OpenVEX assertion",
"issuer": "vendor.example",
"signed": true,
"signedBy": "CN=Vendor VEX Signer",
"contentHash": "hex",
"createdAt": "2025-12-15T22:10:00Z",
"previewUrl": "/api/triage/v1/evidence/uuid/preview",
"rawUrl": "/api/triage/v1/evidence/uuid/raw"
}
]
}
type is a closed wire vocabulary and is emitted exactly as one of: SBOM_SLICE, VEX_DOC, PROVENANCE, CALLSTACK_SLICE, REACHABILITY_PROOF, REPLAY_MANIFEST, POLICY, SCAN_LOG, or OTHER. CLR/PascalCase enum names are not part of the API contract.
3.2 Get raw evidence object
GET /evidence/{evidenceId}/raw
Returns:
application/jsonfor JSON evidenceapplication/octet-streamfor binary- MUST include
Content-SHA256header (hex) when possible.
3.3 Preview evidence object
GET /evidence/{evidenceId}/preview
Returns a compact representation safe for UI preview.
4. Decisions
4.1 Create decision
POST /decisions
Request body:
{
"caseId": "uuid",
"kind": "MUTE_REACH",
"reasonCode": "NON_REACHABLE",
"note": "No entry path in this env; reviewed runtime traces.",
"ttl": "2026-01-16T00:00:00Z"
}
Response 201:
{
"decision": {
"id": "uuid",
"kind": "MUTE_REACH",
"reasonCode": "NON_REACHABLE",
"note": "No entry path in this env; reviewed runtime traces.",
"ttl": "2026-01-16T00:00:00Z",
"actor": { "subject": "user:abc", "display": "Vlad" },
"createdAt": "2025-12-16T01:10:00Z",
"signatureRef": "dsse:rekor:uuid"
}
}
Rules:
- Server signs decisions (DSSE) and persists signature reference.
- Creating a decision MUST create a
Snapshotwith triggerDECISION.
4.2 Revoke decision
POST /decisions/{decisionId}/revoke
Body (optional):
{ "reason": "Mistake; reachability now observed." }
Response 200:
{ "revokedAt": "2025-12-16T02:00:00Z", "signatureRef": "dsse:rekor:uuid" }
5. Snapshots & Smart-Diff
5.1 List snapshots
GET /cases/{caseId}/snapshots
Response 200:
{
"caseId": "uuid",
"items": [
{
"id": "uuid",
"trigger": "POLICY_UPDATE",
"changedAt": "2025-12-16T00:00:00Z",
"fromInputsHash": "hex",
"toInputsHash": "hex",
"summary": "Policy version changed; gate threshold crossed."
}
]
}
5.2 Smart-Diff between two snapshots
GET /cases/{caseId}/smart-diff?from={inputsHashA}&to={inputsHashB}
Response 200:
{
"fromInputsHash": "hex",
"toInputsHash": "hex",
"inputsChanged": [
{ "key": "policyVersion", "before": "2025.12.14", "after": "2025.12.16", "evidenceIds": ["uuid"] }
],
"outputsChanged": [
{ "key": "verdict", "before": "SHIP", "after": "BLOCK", "evidenceIds": ["uuid"] }
]
}
6. Export Evidence Bundle
6.1 Start export
POST /cases/{caseId}/export
Response 202:
{
"exportId": "uuid",
"status": "QUEUED"
}
6.2 Poll export
GET /exports/{exportId}
Response 200:
{
"exportId": "uuid",
"status": "READY",
"downloadUrl": "/api/triage/v1/exports/uuid/download"
}
6.3 Download bundle
GET /exports/{exportId}/download
Returns:
application/zip- DSSE envelope embedded (or alongside in zip)
- bundle contains replay manifest, artifacts, risk result, snapshots
7. Events (Notify.WebService integration)
These are emitted by notify.webservice when scanner outputs change.
first_signal- fired on first actionable detection for an asset/environment
risk_changed- fired when verdict/lane changes or thresholds crossed
gate_blocked- fired when CI gate blocks
Event payload includes:
- caseId
- old/new verdict/lane/score (for changed events)
- inputsHash
- links to
/cases/{caseId}
8. Gating Explainability
8.1 GatingReason enum
Reason why a finding is hidden by default in quiet-by-design triage:
| Value | Description |
|---|---|
none | Not gated - visible in default view |
unreachable | Not reachable from any application entrypoint |
policy_dismissed | Waived or tolerated by policy rules |
backported | Patched via distro backport |
vex_not_affected | VEX statement declares not affected with sufficient trust |
superseded | Superseded by newer advisory |
user_muted | Explicitly muted by user |
8.2 GatedBucketsSummary
{
"unreachableCount": 1904,
"policyDismissedCount": 234,
"backportedCount": 456,
"vexNotAffectedCount": 513,
"supersededCount": 12,
"userMutedCount": 18,
"totalHiddenCount": 3137
}
8.3 VEX Trust Scoring
VEX statements include trust scoring for policy-driven acceptance:
{
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"issuedBy": "vendor.example",
"issuedAt": "2025-12-15T10:00:00Z",
"trustScore": 0.85,
"policyTrustThreshold": 0.80,
"meetsPolicyThreshold": true,
"trustBreakdown": {
"authority": 0.90,
"accuracy": 0.85,
"timeliness": 0.80,
"verification": 0.85
}
}
Trust score components:
- Authority (0-1): Issuer reputation and category
- Accuracy (0-1): Historical correctness
- Timeliness (0-1): Response speed
- Verification (0-1): Signature validity
8.4 Finding gating fields
Extended fields on finding response:
| Field | Type | Description |
|---|---|---|
gatingReason | string | Why this finding is hidden (null if visible) |
isHiddenByDefault | boolean | True if gated by default view |
subgraphId | string | Link to reachability subgraph |
deltasId | string | Link to delta comparison |
gatingExplanation | string | Human-readable explanation |
9. Unified Evidence
9.1 Get unified evidence
GET /findings/{findingId}/evidence
Returns all available evidence tabs in one call for the tenant-scoped Triage finding identified by the bare UUID. The response is still 200 OK when the finding exists but one or more child evidence producers have not produced a row. In that case, the absent tab is null and availability.missing[] identifies the evidence type, stable absence code, owning producer, and recovery action.
Response 200:
{
"findingId": "uuid",
"cveId": "CVE-2024-1234",
"componentPurl": "pkg:npm/lodash@4.17.20",
"scan": {
"scanId": "uuid",
"status": "completed",
"imageReference": "registry.example/app@sha256:...",
"imageDigest": "sha256:...",
"startedAt": "2026-07-11T10:00:00Z",
"completedAt": "2026-07-11T10:01:00Z"
},
"findingTimestamps": {
"firstSeenAt": "2026-07-11T10:01:00Z",
"lastSeenAt": "2026-07-11T10:01:00Z",
"updatedAt": "2026-07-11T10:01:00Z"
},
"sbom": { ... },
"reachability": null,
"vexClaims": [ ... ],
"attestations": [ ... ],
"deltas": { ... },
"policy": { ... },
"manifests": {
"artifactDigest": "sha256:...",
"feedDigest": "sha256:...",
"policyDigest": "sha256:...",
"manifestDigest": "sha256:..."
},
"availability": {
"status": "partial",
"missing": [
{
"type": "reachability",
"code": "scanner.triage.reachability_not_produced",
"producer": "scanner.reachability",
"recovery": "Run reachability analysis for the finding's scan and retry."
}
]
},
"verification": {
"status": "unknown",
"hashesVerified": false,
"attestationsVerified": false,
"evidenceComplete": false,
"verifiedAt": null
},
"replayCommand": null,
"generatedAt": "2025-12-16T01:02:03Z"
}
Scanner publishes only persisted values. It does not synthesize missing hashes, SBOM versions, policy versions, verification success, or replay inputs. Unknown values remain null or unknown. When equal-timestamp child rows exist, the latest-row selectors use UUID ordering as the deterministic tie-break.
Response 404 uses Problem Details with code scanner.triage.finding_not_found, producer scanner.triage, and recovery guidance to query the inbox. It deliberately does not echo the requested UUID, so tenant boundaries do not disclose record existence.
9.2 Export evidence bundle
GET /findings/{findingId}/evidence/export?format=zip
Downloads complete evidence package as archive (ZIP or TAR.GZ).
Response headers:
Content-Type: application/zip or application/gzipContent-Disposition: attachment; filename=“evidence-{findingId}.zip”X-Archive-Digest: sha256:{digest}
9.3 Get replay command
GET /findings/{findingId}/replay-command
Returns copy-ready CLI command for deterministic reproduction.
Response 200:
{
"fullCommand": "stella scan replay --artifact sha256:abc... --manifest sha256:def...",
"shortCommand": "stella replay snapshot --verdict V-12345",
"bundleDownloadUrl": "/api/v1/triage/findings/uuid/evidence/export",
"inputHashes": {
"artifactDigest": "sha256:...",
"manifestHash": "sha256:...",
"feedSnapshotHash": "sha256:...",
"policyHash": "sha256:..."
}
}
See Also
- Triage Evidence Export API Reference — evidence-bundle export and replay commands in depth.
- Unified Evidence Model — the evidence payload returned by the unified-evidence endpoints.
- Smart-Diff API Types — the material-change types behind the Smart-Diff endpoints.
Document version: 1.1 (revision of the v1 contract) · Updated: 2025-12-24 · Target platform: .NET 10, PostgreSQL ≥ 16
