Findings Ledger Export HTTP Surface
Prep task: PREP-LEDGER-EXPORT-35-001-NO-HTTP-API-SURFACE (Sprint 0121)
Goals
- Publish an HTTP surface for deterministic, offline-friendly exports of Findings Ledger data (findings, VEX, advisories, SBOMs) so downstream SDK/OpenAPI tasks can proceed.
- Define filter contract, pagination, media types, and provenance fields required by Evidence Locker and Policy Engine consumers.
Non-goals
- Implementing the endpoints (covered by LEDGER-EXPORT-35-001).
- Final OAS/SDK generation (tracked in PREP-LEDGER-OAS-61-001/002/62-001/63-001).
Base Service
- Host: findings-ledger service (minimal API) under
src/Findings/StellaOps.Findings.Ledger. - Base path:
/ledger/export. - Auth: service-to-service bearer token; require
scope=ledger.export.read. - Tenancy:
X-StellaOps-TenantIdheader (strictly required); responses never mix tenants. - Determinism: server sorts by
(event_sequence, projection_version, cycle_hash); pagination tokens encode the last emitted tuple and filter set. No wall-clock dependence. - Media types: default
application/x-ndjson; clients may requestapplication/json(array) for small result sets. Always emitContent-Encoding: gzipwhenAccept-Encodingallows.
Endpoints
1) Findings
GET /ledger/export/findings- Filters (all optional unless noted):
shape(required):canonical|compact(controls payload shape; compact strips verbose provenance fields for air-gap bundles)since_sequence(long, ≥0),until_sequence(long, inclusive) — enables range slicing.since_observed_at,until_observed_at(ISO-8601 UTC) — observation window.advisory_id(repeatable),component_purl(repeatable),finding_status(open|fixed|dismissed),severity(critical|high|medium|low|unknown).risk_profile_version(string) — filters by attached risk profile revision.
- Response item (canonical shape):
finding_id,event_sequence,observed_at,component(purl, version, source),advisories(ids, cwes),status,severity,risk(score, severity, profile_version, explanation_id),projection_version,cycle_hash,evidence_bundle_ref(digest, dsse_digest, timeline_ref),provenance(ledger_root, projector_version, policy_version, datasource_ids).
2) VEX
GET /ledger/export/vex- Filters:
shape,since_sequence/until_sequence,since_observed_at/until_observed_at,product_id(repeatable),advisory_id,status(affected|not_affected|under_investigation),statement_type(exploitation|justification). - Response item adds
vex_statement_id,product(purl or CPE),status_justification,known_exploited(bool),timestamp,projection_version,cycle_hash,provenance.
3) Advisories
GET /ledger/export/advisories- Filters:
shape,since_sequence/until_sequence,severity,source(feed id),cwe_id,kev(bool),cvss_version,cvss_score_min,cvss_score_max. - Response item:
advisory_id,source,title,description,cwes,cvss(version, vector, base_score),published,modified,status,epss(score, percentile),projection_version,cycle_hash,provenance.
4) SBOMs
GET /ledger/export/sboms- Filters:
shape,since_sequence/until_sequence,since_observed_at/until_observed_at,subject_digest(OCI digest),sbom_format(spdx-json|cyclonedx-json),component_purl(repeatable),contains_native(bool),slsa_build_type(string). - Response item:
sbom_id,subject(digest, media_type),sbom_format,created_at,components_count,has_vulnerabilities(bool),materials(digests),projection_version,cycle_hash,provenance.
Pagination
- Cursor param:
page_token(opaque base64url JSON:{ "last": { "event_sequence": long, "projection_version": string, "cycle_hash": string }, "filters_hash": sha256 }). - Page size:
page_size(default 500, max 5000). Server rejects ifpage_sizediffers across pages for same token. - Response envelope (for both NDJSON and JSON array):
- Header
X-Stella-Next-Page-Tokenwhen more data exists. - Header
X-Stella-Result-Countwith items count. - When
Prefer: return=minimalis set, omit envelope body for NDJSON; clients rely on headers.
- Header
Error Contract
- 400: unknown filter, invalid range, or filter combination mismatch with
filters_hashinsidepage_token. - 401/403: missing or insufficient scope.
- 409: requested
shapenot compatible with downstream air-gap mode (compact requested where policy mandates canonical). - 429: enforcement of determinism guard (server detected projection drift vs cycle_hash); include
X-Stella-Drift-Reason.
Observability & Determinism Hooks
- Emit structured logs
ledger.export.requestandledger.export.emitwith tenant, endpoint, filters_hash, page_size, result_count, duration_ms. - Counters:
ledger_export_items_total{endpoint,tenant}andledger_export_failures_total{endpoint,reason}. - Traces: span name
ledger.export.{endpoint}; attachfilters_hash,page_size,next_page_token_presentattributes.
Ledger list surface — GET /api/v1/findings/ledger
Added by Sprint SPRINT_20260518_065 (TOPO-103) and Sprint SPRINT_20260520_091. Distinct from the planned /ledger/export surface above: this is the live, tenant-scoped read endpoint two cross-service verification flows depend on.
- Route:
GET /api/v1/findings/ledger - Auth:
RequireAuthorization("ledger.export.read")(shares the existing ledger read scope; the samefindings:readbundle that gates{guid}/chain-verify). - Tenancy: resolved from the envelope-bound JWT claim via
StellaOpsTenantResolver(the gateway convertsX-StellaOps-TenantIdat ingress); results never cross tenants. - Exactly one of two mutually-exclusive filters is required:
?runId=<token>— opaque run identifier. A GUID token is matched verbatim againstledger_events.source_run_id; a free-form token (e.g.advanced-assurance-golden-a) is resolved to a stable derived GUID viaLedgerDeterministicIds.ResolveRunId. Returns404 run_not_foundwhen no rows match so the Tester replay-and-compare oracle can distinguish an absent run from an empty one. Rows ordered(chain_id, sequence_no).?actorRef=<guid>— the opaque, erasable Sprint-078 actor reference (theshared.actor_identityjoin key). Returns200withcount:0(never 404) for an actor with no findings activity, so the SAR aggregator records an explicit per-service entry. Rows orderedrecorded_at DESC.- Neither / both filters →
400 filter_required.
- Response envelope:
{ tenantId, runId, actorRef, count, items[] }.items[]uses the array property nameitemsso the Tester’s array-discovery resolves it. Each item is PII-free:findingId(sort key),eventId,chainId,sequenceNumber,eventType,policyVersion,artifactId,actorType,actorRef(opaque UUID or null),eventHash,previousHash,merkleLeafHash,evidenceBundleRef,occurredAt,recordedAt. The raw actor subject (OIDCsub) is never emitted (Sprint 078 no-PII contract). - Index:
ix_ledger_events_source_run_idon(tenant_id, source_run_id, chain_id, sequence_no) WHERE source_run_id IS NOT NULL(migration020_ledger_source_run_id_index.sql);actorRefreuses the Sprint-078ix_ledger_events_actor_refindex.
Consumers
- Replay determinism (Tester
POST /api/v1/tester/replay-and-compare): the oracle’sfindingsscope GETs…/ledger?runId=<token>, strips its declared volatile fields, sorts byfindingId, and SHA-256-compares two runs. With this endpoint the previous404 run_not_foundfor thefindingsscope is resolved; identical/drift verdicts require runId-keyed datasets to exist (see remaining work below). - Subject Access Request (Platform
GET /api/v1/operator/sar/{actorRef}): the PlatformFindingsSubjectAccessJoinerreaches this endpoint byactorRefto surface a subject’s findings-owned ledger activity. The Findings read surface now exists; wiring a realIFindingsSubjectAccessProjectionSourceHTTP adapter insrc/Platform(replacingEmptyFindingsSubjectAccessProjectionSource) is the remaining cross-module step to dropfindingsfrom SARmissingServices.
artefact location
- This document:
docs/modules/findings-ledger/export-http-surface.md(hash stability: keep deterministic ordering as authored on 2025-11-20). - Link from sprint 0121 PREP-LEDGER-EXPORT-35-001; downstream tasks should reference this path for contract details.
