Findings Ledger storage and historical schema design

Owners: Findings Ledger Guild • Vuln Explorer Guild
Currency (2026-09-21): the column sketches below are the historical LEDGER-29-001 design, not an executable specification. Current schema authority is the embedded consolidated src/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/ chain. See retention and custody for current disposal behavior. Re-verify with ConsolidatedFindingsSchemaTests and FindingsTenantLifecycleHandlerTests.

Snapshot storage retired (2026-10-06, PCD-316): forward startup migration 024 removes the unused empty findings.ledger_snapshots table. It refuses to delete historical rows. The snapshot EF entity, mapping and Findings audit actions are withdrawn; there is no current snapshot or time-travel API. Applied migration SQL remains historical authority.

1. Storage profile

ConcernDecisionNotes
EnginePostgreSQL 16+ with UTF-8, jsonb, and partitioning supportAligns with shared data plane; deterministic ordering enforced via primary keys.
TenancyFive ledger/projection parents use LIST partitioning by tenant_idTenant isolation; offboarding archives four record parents and drops the rebuildable findings_projection. This is not an age window.
Time zoneAll timestamps stored as timestamptz UTCCanonical JSON uses ISO-8601 (yyyy-MM-ddTHH:mm:ss.fffZ).
HashingSHA-256 (lower-case hex) over canonical JSONImplemented client-side and verified by DB constraint.
MigrationsSQL files under src/Findings/__Libraries/StellaOps.Findings.Persistence/MigrationsFindings hosts apply the selected chain at startup in their owned database; old ledger SQL is retained history.

2. Historical design sketch — verify columns against the consolidated migration chain

Events are immutable append-only records representing every workflow change. Records capture the original event payload, cryptographic hashes, and actor metadata.

2.1 ledger_events

ColumnTypeDescription
tenant_idtextTenant partition key.
chain_iduuidLogical chain grouping (per tenant/policy combination).
sequence_nobigintMonotonic sequence within a chain (gapless).
event_iduuidGlobally unique event identifier.
event_typeledger_event_typeEnumerated type (see §2.2).
policy_versiontextPolicy digest (e.g., SHA-256).
finding_idtextStable finding identity (artifactId + vulnId + policyVersion).
artifact_idtextAsset identifier (image digest, SBOM id, etc.).
source_run_iduuidPolicy run that produced the event (nullable).
actor_idtextOperator/service initiating the mutation.
actor_typetextsystem, operator, integration.
occurred_attimestamptzDomain timestamp supplied by source.
recorded_attimestamptzIngestion timestamp (defaults to now()).
event_bodyjsonbCanonical payload (see §2.3).
event_hashchar(64)SHA-256 over canonical payload envelope.
previous_hashchar(64)Hash of prior event in chain (all zeroes for first).
merkle_leaf_hashchar(64)Leaf hash used for Merkle anchoring (hash over `event_hash
evidence_bundle_reftextOptional reference to evaluation/job evidence bundle (DSSE or capsule id).

Constraints & indexes

PRIMARY KEY (tenant_id, chain_id, sequence_no);
UNIQUE (tenant_id, event_id);
UNIQUE (tenant_id, chain_id, event_hash);
CHECK (event_hash ~ '^[0-9a-f]{64}$');
CHECK (previous_hash ~ '^[0-9a-f]{64}$');
CREATE INDEX ix_ledger_events_finding ON ledger_events (tenant_id, finding_id, policy_version);
CREATE INDEX ix_ledger_events_type ON ledger_events (tenant_id, event_type, recorded_at DESC);
CREATE INDEX ix_ledger_events_finding_evidence_ref ON ledger_events (tenant_id, finding_id, recorded_at DESC) WHERE evidence_bundle_ref IS NOT NULL;

Partitions: top-level partitioned by tenant_id (list) with a default partition. The current chain has no monthly retention sub-partitions. PostgreSQL requires the partition key in unique constraints; global uniqueness for event_id is enforced as (tenant_id, event_id) with application-level guards maintaining cross-tenant uniqueness.

2.2 Event types

CREATE TYPE ledger_event_type AS ENUM (
  'finding.created',
  'finding.status_changed',
  'finding.severity_changed',
  'finding.tag_updated',
  'finding.comment_added',
  'finding.assignment_changed',
  'finding.accepted_risk',
  'finding.remediation_plan_added',
  'finding.attachment_added',
  'finding.closed',
  'nis2.incident.opened',
  'nis2.incident.classified',
  'nis2.incident.reported',
  'nis2.incident.closed',
  'cra.incident.opened',
  'cra.incident.classified',
  'cra.incident.reported',
  'cra.incident.closed'
);

Additional types can be appended via migrations; canonical JSON must include event_type key.

2.3 Canonical ledger JSON

Canonical payload envelope (before hashing):

{
  "event": {
    "id": "3ac1f4ef-3c26-4b0d-91d4-6a6d3a5bde10",
    "type": "finding.status_changed",
    "tenant": "tenant-a",
    "chainId": "5fa2b970-9da2-4ef4-9a63-463c5d98d3cc",
    "sequence": 42,
    "policyVersion": "sha256:5f38...",
    "finding": {
      "id": "artifact:sha256:abc|pkg:cpe:/o:vendor:product",
      "artifactId": "sha256:abc",
      "vulnId": "CVE-2025-1234"
    },
    "actor": {
      "id": "user:alice@tenant",
      "type": "operator"
    },
    "occurredAt": "2025-11-03T15:12:05.123Z",
    "payload": {
      "previousStatus": "affected",
      "status": "triaged",
      "justification": "Ticket SEC-1234 created",
      "ticket": {
        "id": "SEC-1234",
        "url": "https://tracker/sec-1234"
      }
    }
  }
}

Canonicalisation rules:

  1. Serialize using UTF-8, no BOM.
  2. Sort object keys lexicographically at every level.
  3. Represent enums/flags as lower-case strings.
  4. Timestamps formatted as yyyy-MM-ddTHH:mm:ss.fffZ (millisecond precision, UTC).
  5. Numbers use decimal notation; omit trailing zeros.
  6. Arrays maintain supplied order.

2.4 NIS2 incident event payloads

The NIS2 incident lifecycle uses the standard ledger.event.v1 envelope with payload schema nis2.incident.ledger.v1. The local Findings Ledger slice owns the replay-safe event shape and hash-chain append behavior for:

All NIS2 incident events are written to a deterministic per-incident chain: LedgerChainIdGenerator.FromTenantSubject(tenant, "nis2-incident::<incidentId>"). Callers must supply event IDs plus occurredAt and recordedAt timestamps; the emitter does not create clocks or IDs for these compliance records. The emitter normalizes strings, formats timestamps as UTC milliseconds, and sorts classification criteria, reachability refs, VEX consensus refs, and evidence refs before canonical hashing.

Payload root:

{
  "nis2": {
    "schemaVersion": "nis2.incident.ledger.v1",
    "incident": {
      "id": "incident-001",
      "state": "classified",
      "previousState": "open",
      "timelineState": "OPEN",
      "timelineMilestone": "notification"
    },
    "classification": {
      "significantImpact": true,
      "severity": "high",
      "criteria": ["cross_border", "service_disruption"],
      "impactSummaryRef": "evidence://impact/incident-001",
      "severityClassificationRef": "evidence://classification/incident-001",
      "operatorApprovalRef": "approval://secops/incident-001"
    },
    "correlation": {
      "reachability": [
        {
          "subgraphRef": "reach://tenant-a/subgraphs/a",
          "graphDigest": "sha256:...",
          "subjectDigest": "sha256:...",
          "runtimeEvidenceRef": "runtime://trace/a",
          "replayBundleRef": "cas://replay/incident-001/a"
        }
      ],
      "vexConsensus": [
        {
          "consensusState": "affected",
          "statementRef": "vex://statement/001",
          "documentDigest": "sha256:...",
          "issuer": "vendor-a",
          "justification": "reachable_code_path",
          "lastObservedAt": "2026-04-30T11:00:00.000Z"
        }
      ],
      "evidenceRefs": [
        {
          "key": "severity_classification_ref",
          "ref": "evidence://classification/incident-001",
          "digest": "sha256:..."
        }
      ]
    },
    "report": {
      "milestone": "notification",
      "payloadHash": "sha256:...",
      "signer": "signer://authority/notify",
      "targetIntake": "csirt://de/bsi",
      "sourceLedgerEventRef": "ledger://tenant-a/incident-001/reported-notification",
      "sentAt": "2026-04-30T12:30:00.000Z",
      "evidenceRefs": [
        {
          "key": "operator_approval_ref",
          "ref": "approval://secops/incident-001"
        }
      ]
    },
    "closure": {
      "reason": "final report accepted",
      "closedStatus": "closed",
      "finalReportBundleRef": "bundle://nis2/final-report/incident-001"
    },
    "evidenceRetention": {
      "mode": "incident",
      "evidenceBundleRef": "dsse://evidence/incident-001",
      "snapshotRef": "snapshot://findings/incident-001",
      "retentionPolicyRef": "evidence-locker.incident-mode",
      "retentionExtensionDays": 60
    }
  }
}

nis2.incident.classified requires at least one reachability subgraph ref and one VEX consensus ref so the Article 23 classification can be reconstructed offline without live Graph/VEX queries. nis2.incident.reported carries the Notify timeline milestone, sha256:<64 lowercase hex> payload hash, signer, target intake, source ledger event ref, and milestone-specific evidence refs. Evidence Locker incident-mode retention is cross-linked through both event.evidenceBundleRef and payload.nis2.evidenceRetention.

2.5 CRA incident event payloads

The CRA Article 14 incident lifecycle reuses the NIS2 incident ledger pattern: standard ledger.event.v1 envelope, caller-supplied IDs and timestamps, deterministic per-incident chains, normalized UTC millisecond timestamps, and stable sorting before canonical hashing. CRA events use payload schema cra.incident.ledger.v1 and event types:

All CRA incident events are written to: LedgerChainIdGenerator.FromTenantSubject(tenant, "cra-incident::<incidentId>"). The payload root is cra; its shape mirrors the NIS2 payload root with CRA classification metadata:

{
  "cra": {
    "schemaVersion": "cra.incident.ledger.v1",
    "incident": {
      "id": "cra-incident-001",
      "state": "classified",
      "previousState": "open",
      "timelineState": "OPEN",
      "timelineMilestone": "vulnerability_notification_72h"
    },
    "classification": {
      "activelyExploited": true,
      "severity": "critical",
      "criteria": ["remote_exploitation", "safety_impact"],
      "impactSummaryRef": "evidence://impact/cra-incident-001",
      "severityClassificationRef": "evidence://classification/cra-incident-001",
      "operatorApprovalRef": "approval://secops/cra-incident-001"
    },
    "correlation": {
      "reachability": [
        {
          "subgraphRef": "reach://tenant-a/subgraphs/a",
          "graphDigest": "sha256:...",
          "subjectDigest": "sha256:...",
          "runtimeEvidenceRef": "runtime://trace/a",
          "replayBundleRef": "cas://replay/cra-incident-001/a"
        }
      ],
      "vexConsensus": [
        {
          "consensusState": "affected",
          "statementRef": "vex://statement/001",
          "documentDigest": "sha256:...",
          "issuer": "vendor-a",
          "justification": "reachable_code_path",
          "lastObservedAt": "2026-04-30T11:00:00.000Z"
        }
      ],
      "evidenceRefs": [
        {
          "key": "severity_classification_ref",
          "ref": "evidence://classification/cra-incident-001",
          "digest": "sha256:..."
        }
      ]
    },
    "report": {
      "milestone": "vulnerability_notification_72h",
      "payloadHash": "sha256:...",
      "signer": "signer://authority/notify",
      "targetIntake": "market-surveillance://eu/cra",
      "sourceLedgerEventRef": "ledger://tenant-a/cra-incident-001/reported-72h",
      "sentAt": "2026-04-30T12:30:00.000Z",
      "evidenceRefs": [
        {
          "key": "operator_approval_ref",
          "ref": "approval://secops/cra-incident-001"
        }
      ]
    },
    "closure": {
      "reason": "final report accepted",
      "closedStatus": "closed",
      "finalReportBundleRef": "bundle://cra/final-report/cra-incident-001"
    },
    "evidenceRetention": {
      "mode": "incident",
      "evidenceBundleRef": "dsse://evidence/cra-incident-001",
      "snapshotRef": "snapshot://findings/cra-incident-001",
      "retentionPolicyRef": "evidence-locker.incident-mode",
      "retentionExtensionDays": 90
    }
  }
}

cra.incident.classified requires at least one reachability subgraph ref and one VEX consensus ref where CRA reportability depends on reachable/vulnerable product state. cra.incident.reported validates the emitted report payload hash as sha256:<64 lowercase hex> and carries the CRA timeline milestone, signer, target intake, source ledger event ref, and milestone evidence refs.

2.6 Versioning & DSSE linkage (FL1, FL6)

Hash pipeline:

canonical_json = CanonicalJsonSerializer.Serialize(envelope)
sha256_bytes = SHA256(canonical_json)
event_hash = HexLower(sha256_bytes)

merkle_leaf_hash = HexLower(SHA256(event_hash || '-' || sequence_no)).

3. Merkle anchoring

Anchoring batches events per tenant across fixed windows (default: 1,000 events or 15 minutes). Anchors are stored in ledger_merkle_roots.

ColumnTypeDescription
tenant_idtextTenant key.
anchor_iduuidAnchor identifier.
window_starttimestamptzInclusive start of batch.
window_endtimestamptzExclusive end.
sequence_startbigintFirst sequence included.
sequence_endbigintLast sequence included.
root_hashchar(64)Merkle root (SHA-256).
leaf_countintegerNumber of events aggregated.
anchored_attimestamptzTimestamp root stored/signed.
anchor_referencetextOptional reference to external ledger (e.g., Rekor UUID).

Indexes: PRIMARY KEY (tenant_id, anchor_id), UNIQUE (tenant_id, root_hash), INDEX ix_merkle_sequences ON ledger_merkle_roots (tenant_id, sequence_end DESC).

4. Projection tables

4.1 findings_projection

Stores the latest verdict/state per finding.

ColumnTypeDescription
tenant_idtextPartition key.
finding_idtextMatches ledger payload.
policy_versiontextActive policy digest.
statustexte.g., affected, triaged, accepted_risk, resolved.
severitynumeric(6,3)Normalised severity score (0-10).
risk_scorenumeric(6,3)Risk scoring result (0-10) from Risk Engine/Policy.
risk_severitytextRisk category (e.g., low, medium, high, critical).
risk_profile_versiontextRisk profile hash/version used for scoring.
risk_explanation_iduuidReference to risk explanation document.
labelsjsonbKey-value metadata (tags, KEV flag, runtime signals).
current_event_iduuidLedger event that produced this state.
explain_reftextReference to explain bundle or object storage key.
policy_rationalejsonbArray of policy rationale references (explain bundle IDs, remediation notes).
updated_attimestamptzLast projection update.
cycle_hashchar(64)Deterministic hash of projection record (used in export bundles).

Primary key: (tenant_id, finding_id, policy_version).

Indexes:

4.2 Retired: finding_history and triage_actions

Migration 028_findings_retire_write_only_projections.sql drops both tables and the ledger_action_type enum. The projector wrote them and nothing read them; every row was a function of ledger_events, which stays the record. A finding’s timeline and its triage actions are read from ledger_events (for example, the alert decision audit timeline).

4.4 ledger_projection_offsets

Checkpoint store for the projection background worker, one cursor per tenant. Ensures idempotent replays across restarts. Primary key (worker_id, tenant_id) since migration 018_findings_ledger_tenant_registry.sql, which copied a pre-existing global cursor once to every registered tenant.

ColumnTypeDescription
worker_idtextLogical worker identifier (defaults to default).
tenant_idtextThe tenant whose ledger events this cursor tracks.
last_recorded_attimestamptzTimestamp of the last projected ledger event of the tenant.
last_event_iduuidEvent identifier paired with last_recorded_at for deterministic ordering.
updated_attimestamptzLast time the checkpoint was persisted.

No row is seeded. A tenant without a row replays from the beginning of its own events (1970-01-01T00:00:00Z with the empty GUID).

4.4a ledger_tenants

The registry of tenants that have ledger events (migration 018). It holds tenant ids only and carries no row-level security: it is the one relation the projector reads before it binds a tenant. The row-level AFTER INSERT trigger ledger_events_register_tenant on ledger_events fills it; PostgreSQL clones the trigger to every partition. ATTACH PARTITION of a pre-filled table fires no trigger; re-run the backfill from the deploy runbook afterwards.

ColumnTypeDescription
tenant_idtextPrimary key; never blank.
first_seen_attimestamptzrecorded_at of the first registered event (the earliest one at backfill time).

4.5 ledger_attestations

Deterministic view of DSSE verification results used by /v1/ledger/attestations. Rows are written by the provenance/verification pipeline and keyed per tenant.

ColumnTypeDescription
tenant_idtextPartition key.
attestation_iduuidPrimary key within tenant.
artifact_idtextOCI digest or SBOM identifier verified.
finding_idtextOptional finding linkage.
verification_statustextverified, failed, or unknown.
verification_timetimestamptzWhen verification completed.
dsse_digesttextLower-case SHA-256 of DSSE envelope.
rekor_entry_idtextOptional transparency log UUID.
evidence_bundle_reftextOptional evidence bundle reference.
ledger_event_iduuidLedger event that linked the attestation.
recorded_attimestamptzIngestion timestamp used for paging.
merkle_leaf_hashtextLeaf hash for anchoring proofs.
root_hashtextAnchor root hash.
cycle_hashtextProjection cycle hash for determinism.
projection_versiontextProjection version identifier.

Ordering and pagination: ORDER BY recorded_at ASC, attestation_id ASC with cursor token {recordedAt, attestationId, filtersHash}. Indexes: PK (tenant_id, attestation_id), paging index (tenant_id, recorded_at, attestation_id), lookups on (tenant_id, artifact_id, recorded_at DESC) and (tenant_id, verification_status, recorded_at DESC).

4.6 asset_registry_events

Stores a Findings-owned visibility projection of the Graph Asset Registry v1 append-only source table (graph.asset_registry_events). Graph remains the source of truth and Findings never writes the graph schema. The projection preserves Graph payloads and hashes unchanged so auditors can compare the Findings row back to the Graph event source.

ColumnTypeDescription
tenant_idtextTenant partition key, protected by Findings Ledger RLS.
graph_event_idtextOriginal Graph event id (asset-event:<event_hash>).
asset_idtextDeterministic Graph asset node id.
asset_typetextClosed Asset Registry v1 type: host, container, image, service, integration, or plugin.
event_typetextasset.created, asset.updated, or asset.removed.
payload_jsonjsonbCanonical Graph asset node JSON from the source event.
payload_hashtextGraph payload hash, preserved unchanged.
previous_event_hashtextPrior Graph event hash for the same asset when present.
merkle_leaf_hashtextGraph Merkle leaf hash, preserved unchanged.
event_hashtextGraph event hash, preserved unchanged.
graph_occurred_attimestamptzGraph source domain timestamp.
graph_recorded_attimestamptzGraph source append timestamp.
projected_attimestamptzFindings projection timestamp from TimeProvider.

Primary key: (tenant_id, graph_event_id). Deterministic timeline reads order by graph_occurred_at ASC, event_hash ASC. The projection worker checkpoints source append order in asset_registry_projection_offsets using graph_recorded_at ASC, graph_event_id ASC so late-arriving source rows are not missed while replay views still use the Graph event ordering contract.

Migration: src/Findings/StellaOps.Findings.Ledger/migrations/017_asset_registry_event_visibility.sql.

5. Hashing & verification

  1. Canonical serialize the envelope (§2.3).
  2. Compute event_hash and store along with previous_hash.
  3. Build Merkle tree per anchoring window using leaf hash SHA256(event_hash || '-' || sequence_no).
  4. Persist root in ledger_merkle_roots and, when configured, submit to external transparency log (Rekor v2). Store receipt/UUID in anchor_reference (see merkle-anchor-policy.md).
  5. Projection rows compute cycle_hash = SHA256(canonical_projection_json) where canonical projection includes fields {tenant_id, finding_id, policy_version, status, severity, labels, current_event_id} with sorted keys.

Verification flow for auditors:

6. Fixtures & migrations

Fixtures follow canonical key ordering and include precomputed hashes to validate tooling.

7. Projection worker

8. Runtime instrumentation persistence

The runtime instrumentation feature persists eBPF/APM trace observations in the existing findings schema. Runtime tables are not Merkle-chain ledger events; they back the runtime trace, aggregate, score, and timeline read models used by the Findings Ledger runtime endpoints.

8.1 runtime_traces

Append-only raw runtime observations.

ColumnTypeDescription
iduuidPrimary key.
tenant_idtextTenant partition key.
finding_iduuidCorrelated finding.
captured_attimestamptzAgent capture timestamp.
artifact_digesttextBuild-artifact digest.
component_purltextSBOM component PURL.
container_idtextOptional container identifier.
container_nametextOptional promoted container name.
framesjsonbRedacted stack frames with symbol/file/line/entry/vulnerable flags.
metadatajsonbOptional durable capture metadata.
correlation_iduuidOptional capture-session identifier.
ingested_attimestamptzServer ingest timestamp.

Indexes cover (tenant_id, finding_id, captured_at DESC), (tenant_id, finding_id, artifact_digest), and sparse correlation_id lookup.

8.2 runtime_trace_aggregates

Denormalized hit counters populated during ingest with atomic INSERT ... ON CONFLICT DO UPDATE.

ColumnTypeDescription
tenant_idtextTenant partition key.
finding_iduuidCorrelated finding.
artifact_digesttextBuild-artifact digest.
vulnerable_functiontextPrivacy-redacted vulnerable symbol.
hit_countbigintAggregated hit counter.
first_seentimestamptzEarliest observation timestamp.
last_seentimestamptzLatest observation timestamp.
container_countintegerMaximum observed container count.
has_entry_pointbooleanWhether persisted frames prove an entry-point path.

Primary key: (tenant_id, finding_id, artifact_digest, vulnerable_function). Indexes cover top-N hit and recent-activity queries per (tenant_id, finding_id).

8.3 runtime_scores

One row per (tenant_id, finding_id), upserted by score derivation.

ColumnTypeDescription
tenant_idtextTenant partition key.
finding_iduuidCorrelated finding.
scorenumeric(5,2)Runtime score from 0.00 to 100.00.
derivation_window_sintegerNon-negative derivation window in seconds.
componentsjsonbComponent-level scoring details.
updated_attimestamptzScore update timestamp.

8.4 runtime_timeline_events

Append-only runtime activity timeline.

ColumnTypeDescription
event_iduuidPrimary key.
tenant_idtextTenant partition key.
finding_iduuidCorrelated finding.
kindtextEvent kind, such as trace_ingested or score_updated.
occurred_attimestamptzEvent timestamp.
correlation_iduuidOptional capture-session identifier.
payloadjsonbStructured event payload.

Indexes cover (tenant_id, finding_id, occurred_at DESC) and sparse correlation_id lookup.

8.5 Migrations and DI

The runtime tables are created by the consolidated Findings chain (src/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/), which findings-web and findings-worker apply through startup migrations. The file names above are the history of that DDL; StellaOps.Findings.Ledger embeds no SQL since its legacy chain was withdrawn (SPRINT_20260928_004 PCD-149).

9. Next steps