Findings Ledger Schema Catalog (FL1–FL3)
Scope: Versioned canonical schemas for ledger events, projections, and exports.
Status: v1.0.0 sealed (2025-12-02) — breaking changes require new minor/major version tags.
1) Ledger event envelope — ledger.event.v1
| Field | Type | Notes |
|---|---|---|
event.id | uuid | V7 GUID allowed. |
event.type | string (ledger_event_type) | See schema.md §2.2. |
event.tenant | string | Partition key. |
event.chainId | uuid | Derived when absent (tenant :: policyVersion), see workflow-inference.md. |
event.sequence | long | Gapless per chain, starts at 1. |
event.policyVersion | string | SHA-256 digest of policy bundle; propagated into exports and DSSE. |
event.finding | object | id, artifactId, vulnId. |
event.actor | object | id, type (`system |
event.occurredAt | string (UTC ISO-8601 ms) | Domain clock. |
event.recordedAt | string (UTC ISO-8601 ms) | Service TimeProvider. |
event.payload | object | Mutation-specific body. |
event.evidenceBundleRef | string? | DSSE/capsule id (optional). |
event.airgap.bundle | object? | See airgap-provenance.md. |
event_hash | char(64) | sha256(canonicalJson) lower-hex. |
previous_hash | char(64) | All-zero for chain genesis. |
merkle_leaf_hash | char(64) | `sha256(event_hash |
Canonicalisation: UTF-8, sorted keys, lower-case enums, ISO-8601 UTC with millisecond precision, arrays stable-order. Any field addition bumps minor version.
NIS2 incident payload - nis2.incident.ledger.v1
nis2.incident.ledger.v1 is the event.payload.nis2 schema used by nis2.incident.{opened|classified|reported|closed} ledger events. It keeps incident state, classification evidence, reachability subgraph refs, VEX consensus refs, report-transmission evidence, closure data, and Evidence Locker incident-mode retention links inside the canonical ledger event body.
Replay rules:
- Event IDs and
occurredAt/recordedAttimestamps are caller-supplied. - Classification criteria, reachability refs, VEX refs, and evidence refs are sorted before hashing.
nis2.incident.classifiedrequires at least one reachability subgraph ref and one VEX consensus ref.nis2.incident.reportedrequires milestone,sha256:<64 lowercase hex>payload hash, signer, target intake, source ledger event ref, and report timestamp.
2) Finding projection — ledger.projection.v1
| Field | Type | Notes |
|---|---|---|
tenantId | string | Partition key. |
findingId | string | Stable identity. |
policyVersion | string | Hash of active policy bundle. |
status | string | `affected |
severity | number | 0–10, 3 decimal places. |
riskScore | number | 0–10, 3 decimal places. |
riskSeverity | string | `low |
riskProfileVersion | string | Version/hash from Risk Engine. |
riskExplanationId | uuid? | Links to explain bundle. |
labels | json | KEV/runtime flags, sorted keys. |
currentEventId | uuid | Source ledger event. |
explainRef | string? | Object storage / DSSE reference. |
policyRationale | json | Array of rationale refs. |
updatedAt | string UTC | Projection timestamp. |
cycleHash | char(64) | sha256(canonicalProjectionJson); used in exports. |
Projection deterministic hash recipe: serialize projection record with sorted keys (excluding updatedAt jitter) and hash via SHA-256. The replay harness recomputes and compares.
3) Export payloads — export.v1
Shapes share headers: policyVersion, projectionVersion (cycle hash), filtersHash, pageToken, observedAt, provenance (ledgerRoot, projectorVersion, policyHash, optional dsseDigest).
Canonical vs compact
- Canonical (
export.v1.canonical) — full provenance fields, evidence refs, DSSE linkage. - Compact (
export.v1.compact) — drops verbose fields (policyRationale, comments, actor ids), keepscycleHash+filtersHashfor determinism; redaction manifest enforced.
Record fields
- Findings:
findingId,eventSequence,status,severity,risk,advisories[],evidenceBundleRef,cycleHash. - VEX:
vexStatementId,product,status,justification,knownExploited,cycleHash. - Advisories:
advisoryId,source,cvss{version,vector,baseScore},epss,kev,cycleHash. - SBOMs:
sbomId,subject{digest,mediaType},sbomFormat,componentsCount,materials[],cycleHash.
Filters hash: sha256(sortedQueryString); stored alongside fixtures for replayability.
4) Versioning rules
- Patch: backward-compatible field additions (new optional key) — bump patch digit.
- Minor: additive required fields or canonical rule tweaks — bump minor.
- Major: breaking change (field removal/rename, hash recipe) — bump major and keep prior schema frozen.
5) Reference artefacts
- Golden fixtures:
src/Findings/StellaOps.Findings.Ledger/fixtures/golden/*.ndjson. - Checksum manifest:
docs/modules/findings-ledger/golden-checksums.json. - Offline verifier:
tools/LedgerReplayHarness/scripts/verify_export.py.
6) Rekor Entry Reference — rekor.entry.ref.v1 (Sprint: SPRINT_20260112_004_FINDINGS)
| Field | Type | Notes |
|---|---|---|
logIndex | long? | Position in the Rekor log. |
logId | string? | Log identifier (hex-encoded public key hash). |
uuid | string? | Unique entry identifier. |
integratedTime | long? | Unix epoch seconds when entry was integrated. |
integratedTimeRfc3339 | string? (UTC ISO-8601) | RFC3339 formatted integrated time for display/sorting. |
entryUrl | string? | Full URL to the Rekor entry for UI linking. It is present only when supplied by the pointer payload or derived from an explicitly configured Rekor base URL. |
Usage: Attached to AttestationPointer records and evidence graph signature metadata. The integratedTimeRfc3339 field provides human-readable timestamps and deterministic sorting. The entryUrl enables direct linking from UI components when the deployment has an approved Rekor mirror or external endpoint configured.
Offline mode: When operating in air-gapped environments, entryUrl is null unless a local Rekor mirror URL is configured or the stored pointer already contains a URL. The integratedTime remains authoritative for timestamp verification.
7) Per-case VEX-consensus trust override — findings.vex_statement_trust_override (Sprint: SPRINT_20260629_001 WS3)
Tenant-scoped table backing the per-case override that lets an operator UNTRUST a VEX-consensus verdict for a specific (vuln, product[, source]). Default-TRUST: in the absence of any row a consensus verdict is trusted (subject to the global EvidenceWeightedScore:TrustConsensusVexByDefault gate); an explicit enabled row with trusted=false suppresses the auto-cap for that case. Migration migrations/003_vex_statement_trust_override.sql (embedded, idempotent, RLS via findings_ledger_app.require_current_tenant()).
| Column | Type | Notes |
|---|---|---|
id | uuid | gen_random_uuid() default. |
tenant_id | text | Partition key; PK is (tenant_id, id). |
vuln_id | text | Vulnerability id (e.g. CVE-…). |
product_key | text | Component purl / product key the consensus applies to. |
vex_source | text? | Optional winning-source scope; NULL = any source for this (vuln, product). |
trusted | bool | false = untrust (the only reason to create a row); default false. |
reason | text? | Operator rationale. |
actor_id | text? | Operator subject id. |
created_at / updated_at | timestamptz | App-managed. |
Uniqueness: partial unique indexes — (tenant_id, vuln_id, product_key, vex_source) where vex_source IS NOT NULL, and (tenant_id, vuln_id, product_key) where vex_source IS NULL (one wildcard row per pair). A source-scoped row wins over the wildcard at resolution time.
CRUD surface: /findings/vex-trust-overrides — GET list / GET {id} (scope findings:read), POST / PUT {id} / DELETE {id} (scope findings:write); tenant from the stellaops:tenant claim (RequireTenant()). A mutation evicts all cached EWS scores (the override keys on (vuln, product), not a finding id).
Apply path: the Findings evidence bridge (AnchoredFindingEvidenceProvider) stamps a trusted not_affected/fixed VexLens consensus onto FindingEvidence.VexConsensus{Status,Source} so the EWS calculator caps the score to 0/Watchlist — gated by (a) the global flag, (b) operator-decision precedence (IDecisionService history), and © this per-case untrust store.
