Findings Ledger Schema (Sprint 120)
Owners: Findings Ledger Guild • Vuln Explorer Guild
Status: Draft schema delivered 2025-11-03 for LEDGER-29-001
1. Storage profile
| Concern | Decision | Notes |
|---|---|---|
| Engine | PostgreSQL 16+ with UTF-8, jsonb, and partitioning support | Aligns with shared data plane; deterministic ordering enforced via primary keys. |
| Tenancy | Range/list partition on tenant_id for ledger + projection tables | Simplifies retention and cross-tenant anchoring. |
| Time zone | All timestamps stored as timestamptz UTC | Canonical JSON uses ISO-8601 (yyyy-MM-ddTHH:mm:ss.fffZ). |
| Hashing | SHA-256 (lower-case hex) over canonical JSON | Implemented client-side and verified by DB constraint. |
| Migrations | SQL files under src/Findings/StellaOps.Findings.Ledger/migrations | Applied via DatabaseMigrator (part of platform toolchain). |
2. Ledger event model
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
| Column | Type | Description |
|---|---|---|
tenant_id | text | Tenant partition key. |
chain_id | uuid | Logical chain grouping (per tenant/policy combination). |
sequence_no | bigint | Monotonic sequence within a chain (gapless). |
event_id | uuid | Globally unique event identifier. |
event_type | ledger_event_type | Enumerated type (see §2.2). |
policy_version | text | Policy digest (e.g., SHA-256). |
finding_id | text | Stable finding identity (artifactId + vulnId + policyVersion). |
artifact_id | text | Asset identifier (image digest, SBOM id, etc.). |
source_run_id | uuid | Policy run that produced the event (nullable). |
actor_id | text | Operator/service initiating the mutation. |
actor_type | text | system, operator, integration. |
occurred_at | timestamptz | Domain timestamp supplied by source. |
recorded_at | timestamptz | Ingestion timestamp (defaults to now()). |
event_body | jsonb | Canonical payload (see §2.3). |
event_hash | char(64) | SHA-256 over canonical payload envelope. |
previous_hash | char(64) | Hash of prior event in chain (all zeroes for first). |
merkle_leaf_hash | char(64) | Leaf hash used for Merkle anchoring (hash over `event_hash |
evidence_bundle_ref | text | Optional 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. Optional sub-partition by month on recorded_at for large tenants. 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:
- Serialize using UTF-8, no BOM.
- Sort object keys lexicographically at every level.
- Represent enums/flags as lower-case strings.
- Timestamps formatted as
yyyy-MM-ddTHH:mm:ss.fffZ(millisecond precision, UTC). - Numbers use decimal notation; omit trailing zeros.
- 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:
nis2.incident.openednis2.incident.classifiednis2.incident.reportednis2.incident.closed
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:
cra.incident.openedcra.incident.classifiedcra.incident.reportedcra.incident.closed
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)
- Canonical schema identifiers are catalogued in
schema-catalog.md(ledger.event.v1,ledger.projection.v1,export.v1.*). - Any change to the envelope, hash recipe, or required fields bumps the catalog version; legacy versions remain frozen.
- DSSE artefacts (anchors, exports, replay reports) must embed
policyVersionandschemaVersion(seedsse-policy-linkage.md).
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.
| Column | Type | Description |
|---|---|---|
tenant_id | text | Tenant key. |
anchor_id | uuid | Anchor identifier. |
window_start | timestamptz | Inclusive start of batch. |
window_end | timestamptz | Exclusive end. |
sequence_start | bigint | First sequence included. |
sequence_end | bigint | Last sequence included. |
root_hash | char(64) | Merkle root (SHA-256). |
leaf_count | integer | Number of events aggregated. |
anchored_at | timestamptz | Timestamp root stored/signed. |
anchor_reference | text | Optional 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.
| Column | Type | Description |
|---|---|---|
tenant_id | text | Partition key. |
finding_id | text | Matches ledger payload. |
policy_version | text | Active policy digest. |
status | text | e.g., affected, triaged, accepted_risk, resolved. |
severity | numeric(6,3) | Normalised severity score (0-10). |
risk_score | numeric(6,3) | Risk scoring result (0-10) from Risk Engine/Policy. |
risk_severity | text | Risk category (e.g., low, medium, high, critical). |
risk_profile_version | text | Risk profile hash/version used for scoring. |
risk_explanation_id | uuid | Reference to risk explanation document. |
labels | jsonb | Key-value metadata (tags, KEV flag, runtime signals). |
current_event_id | uuid | Ledger event that produced this state. |
explain_ref | text | Reference to explain bundle or object storage key. |
policy_rationale | jsonb | Array of policy rationale references (explain bundle IDs, remediation notes). |
updated_at | timestamptz | Last projection update. |
cycle_hash | char(64) | Deterministic hash of projection record (used in export bundles). |
Primary key: (tenant_id, finding_id, policy_version).
Indexes:
ix_projection_statuson(tenant_id, status, severity DESC).ix_projection_riskon(tenant_id, risk_severity, risk_score DESC).ix_projection_labels_ginusinglabelsGIN for KEV/runtime filters.
4.2 finding_history
Delta view derived from ledger events for quick UI queries.
| Column | Type | Description |
|---|---|---|
tenant_id | text | Partition key. |
finding_id | text | Finding identity. |
policy_version | text | Policy digest. |
event_id | uuid | Ledger event ID. |
status | text | Status after event. |
severity | numeric(6,3) | Severity after event (nullable). |
actor_id | text | Actor performing change. |
comment | text | Optional summary/message. |
occurred_at | timestamptz | Domain event timestamp. |
Materialized view or table updated by projector. Indexed by (tenant_id, finding_id, occurred_at DESC).
4.3 triage_actions
Audit table for operator actions needing tailored queries.
| Column | Type | Description |
|---|---|---|
tenant_id | text | Partition key. |
action_id | uuid | Primary key. |
event_id | uuid | Source ledger event. |
finding_id | text | Finding identity. |
action_type | ledger_action_type | e.g., assign, comment, attach_evidence, link_ticket. |
payload | jsonb | Structured action body (canonical stored separately). |
created_at | timestamptz | Timestamp stored. |
created_by | text | Actor ID. |
ledger_action_type enum mirrors CLI/UX operations.
CREATE TYPE ledger_action_type AS ENUM (
'assign',
'comment',
'attach_evidence',
'link_ticket',
'remediation_plan',
'status_change',
'accept_risk',
'reopen',
'close'
);
### 4.4 `ledger_projection_offsets`
Checkpoint store for the projection background worker. Ensures idempotent replays across restarts.
| Column | Type | Description |
|--------|------|-------------|
| `worker_id` | `text` | Logical worker identifier (defaults to `default`). |
| `last_recorded_at` | `timestamptz` | Timestamp of the last projected ledger event. |
| `last_event_id` | `uuid` | Event identifier paired with `last_recorded_at` for deterministic ordering. |
| `updated_at` | `timestamptz` | Last time the checkpoint was persisted. |
Seed row inserted on migration ensures catch-up from epoch (`1970-01-01T00:00:00Z` with empty GUID).
### 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.
| Column | Type | Description |
|--------|------|-------------|
| `tenant_id` | `text` | Partition key. |
| `attestation_id` | `uuid` | Primary key within tenant. |
| `artifact_id` | `text` | OCI digest or SBOM identifier verified. |
| `finding_id` | `text` | Optional finding linkage. |
| `verification_status` | `text` | `verified`, `failed`, or `unknown`. |
| `verification_time` | `timestamptz` | When verification completed. |
| `dsse_digest` | `text` | Lower-case SHA-256 of DSSE envelope. |
| `rekor_entry_id` | `text` | Optional transparency log UUID. |
| `evidence_bundle_ref` | `text` | Optional evidence bundle reference. |
| `ledger_event_id` | `uuid` | Ledger event that linked the attestation. |
| `recorded_at` | `timestamptz` | Ingestion timestamp used for paging. |
| `merkle_leaf_hash` | `text` | Leaf hash for anchoring proofs. |
| `root_hash` | `text` | Anchor root hash. |
| `cycle_hash` | `text` | Projection cycle hash for determinism. |
| `projection_version` | `text` | Projection 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.
| Column | Type | Description |
|--------|------|-------------|
| `tenant_id` | `text` | Tenant partition key, protected by Findings Ledger RLS. |
| `graph_event_id` | `text` | Original Graph event id (`asset-event:<event_hash>`). |
| `asset_id` | `text` | Deterministic Graph asset node id. |
| `asset_type` | `text` | Closed Asset Registry v1 type: `host`, `container`, `image`, `service`, `integration`, or `plugin`. |
| `event_type` | `text` | `asset.created`, `asset.updated`, or `asset.removed`. |
| `payload_json` | `jsonb` | Canonical Graph asset node JSON from the source event. |
| `payload_hash` | `text` | Graph payload hash, preserved unchanged. |
| `previous_event_hash` | `text` | Prior Graph event hash for the same asset when present. |
| `merkle_leaf_hash` | `text` | Graph Merkle leaf hash, preserved unchanged. |
| `event_hash` | `text` | Graph event hash, preserved unchanged. |
| `graph_occurred_at` | `timestamptz` | Graph source domain timestamp. |
| `graph_recorded_at` | `timestamptz` | Graph source append timestamp. |
| `projected_at` | `timestamptz` | Findings 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:
- Fetch event, recompute canonical hash, validate `previous_hash` chain.
- Reconstruct Merkle path from stored leaf hash; verify matches recorded root.
- Service callers can use
`GET /api/v1/findings/ledger/{ledgerId}/chain-verify` to run the
tenant-scoped chain verifier without exporting raw ledger rows. The response
is limited to `status` (`verified`, `tampered`, or `missing`), `rootHash`,
`verifiedAt`, `entryCount`, and `firstTamperedSeq` when the first divergent
sequence is known.
- Cross-check projection `cycle_hash` matches ledger state derived from last event.
- Alert evidence bundle download produces a deterministic gzip tar archive with
`manifest.json`, `alert.json`, and `evidence.json`. The verify endpoint
regenerates the archive under the resolved request tenant and compares
SHA-256 hashes so offline callers can prove bundle byte integrity without
crossing tenant boundaries. Composite alert IDs that include image path
slashes must use the query-form endpoints `/v1/alerts/summary?alert_id=...`,
`/v1/alerts/decisions?alert_id=...`, `/v1/alerts/audit?alert_id=...`,
`/v1/alerts/bundle?alert_id=...`, and
`/v1/alerts/bundle/verify?alert_id=...` instead of the single path segment
routes. If callers supply a `signature`, it must be DSSE envelope JSON whose
payload either hashes to the regenerated bundle hash or contains a
`sha256`/`bundleHash` binding to it. The verifier checks the signature
against configured PEM trust roots under
`findings:ledger:evidenceBundles:dsseVerification`; malformed envelopes,
missing trust roots, payload mismatches, and signature mismatches fail closed.
- Notify delivery correlation for the production assurance seed uses the
Notify-owned `findings.production_alert.evidence_seeded` producer event. The
event payload carries the alert id, artifact, vulnerability, component,
environment, evidence bundle ref, evidence hash, recipient, and configured
Notify channel id; Notify persists a queued delivery row keyed by the event id
and queryable by the alert/evidence correlation id. Findings remains the
source for the alert and evidence content; Notify owns channel readiness and
delivery state.
## 6. Fixtures & migrations
- Initial migration script: `src/Findings/StellaOps.Findings.Ledger/migrations/001_initial.sql`.
- Sample canonical event: `src/__Tests/__Datasets/seed-data/findings-ledger/fixtures/ledger-event.sample.json` (includes pre-computed `eventHash`, `previousHash`, and `merkleLeafHash` values).
- Sample projection row: `src/__Tests/__Datasets/seed-data/findings-ledger/fixtures/finding-projection.sample.json` (includes canonical `cycleHash` for replay validation).
- Golden export fixtures (FL7): `src/Findings/StellaOps.Findings.Ledger/fixtures/golden/*.ndjson` with checksums in `docs/modules/findings-ledger/golden-checksums.json`.
- Redaction manifest (FL5): `docs/modules/findings-ledger/redaction-manifest.yaml` governs mask/drop rules for canonical vs compact exports.
Fixtures follow canonical key ordering and include precomputed hashes to validate tooling.
## 7. Projection worker
- `LedgerProjectionWorker` consumes ledger events via `PostgresLedgerEventStream`, applying deterministic reductions with `LedgerProjectionReducer`.
- Checkpoint state is stored in `ledger_projection_offsets`, allowing replay from any point in time.
- Batch processing is configurable via `findings:ledger:projection` (`batchSize`, `idleDelay`).
- Each event writes:
- `findings_projection` (upserted current state with `cycle_hash`).
- `finding_history` (timeline entry keyed by event ID).
- `triage_actions` when applicable (status change, comment, assignment, remediation, attachment, accept-risk, close).
- `AssetRegistryLedgerProjectionWorker` consumes the Graph-owned
`graph.asset_registry_events` source read-only and mirrors created, updated,
and removed asset events into `findings.asset_registry_events`. It uses the
same `findings:ledger:projection` batch/idle settings, preserves Graph hashes
unchanged, and tolerates a missing Graph schema during startup so Findings can
boot before Graph migrations have run and catch up later.
## 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.
| Column | Type | Description |
|--------|------|-------------|
| `id` | `uuid` | Primary key. |
| `tenant_id` | `text` | Tenant partition key. |
| `finding_id` | `uuid` | Correlated finding. |
| `captured_at` | `timestamptz` | Agent capture timestamp. |
| `artifact_digest` | `text` | Build-artifact digest. |
| `component_purl` | `text` | SBOM component PURL. |
| `container_id` | `text` | Optional container identifier. |
| `container_name` | `text` | Optional promoted container name. |
| `frames` | `jsonb` | Redacted stack frames with symbol/file/line/entry/vulnerable flags. |
| `metadata` | `jsonb` | Optional durable capture metadata. |
| `correlation_id` | `uuid` | Optional capture-session identifier. |
| `ingested_at` | `timestamptz` | Server 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`.
| Column | Type | Description |
|--------|------|-------------|
| `tenant_id` | `text` | Tenant partition key. |
| `finding_id` | `uuid` | Correlated finding. |
| `artifact_digest` | `text` | Build-artifact digest. |
| `vulnerable_function` | `text` | Privacy-redacted vulnerable symbol. |
| `hit_count` | `bigint` | Aggregated hit counter. |
| `first_seen` | `timestamptz` | Earliest observation timestamp. |
| `last_seen` | `timestamptz` | Latest observation timestamp. |
| `container_count` | `integer` | Maximum observed container count. |
| `has_entry_point` | `boolean` | Whether 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.
| Column | Type | Description |
|--------|------|-------------|
| `tenant_id` | `text` | Tenant partition key. |
| `finding_id` | `uuid` | Correlated finding. |
| `score` | `numeric(5,2)` | Runtime score from `0.00` to `100.00`. |
| `derivation_window_s` | `integer` | Non-negative derivation window in seconds. |
| `components` | `jsonb` | Component-level scoring details. |
| `updated_at` | `timestamptz` | Score update timestamp. |
### 8.4 `runtime_timeline_events`
Append-only runtime activity timeline.
| Column | Type | Description |
|--------|------|-------------|
| `event_id` | `uuid` | Primary key. |
| `tenant_id` | `text` | Tenant partition key. |
| `finding_id` | `uuid` | Correlated finding. |
| `kind` | `text` | Event kind, such as `trace_ingested` or `score_updated`. |
| `occurred_at` | `timestamptz` | Event timestamp. |
| `correlation_id` | `uuid` | Optional capture-session identifier. |
| `payload` | `jsonb` | Structured event payload. |
Indexes cover `(tenant_id, finding_id, occurred_at DESC)` and sparse
`correlation_id` lookup.
### 8.5 Migrations and DI
- `011_runtime_traces.sql` creates traces and aggregate counters.
- `012_runtime_scores_timeline.sql` creates scores and timeline events.
- `013_runtime_aggregates_entry_point.sql` promotes entry-point evidence on
aggregate rows.
- `014_runtime_traces_container_metadata.sql` promotes container name and
metadata from accepted input into durable columns.
- `AddFindingsLedgerRuntimePersistence(IConfiguration)` registers the runtime
repositories; disabled runtime mode resolves to null repositories that return
empty reads and no-op writes.
All runtime SQL files are embedded in `StellaOps.Findings.Ledger` through the
module migration resource glob and apply through startup migrations.
## 9. Next steps
- Integrate Policy Engine batch evaluation with the projector (`LEDGER-29-004`).
- Align Vulnerability Explorer queries with the new projection state and timeline endpoints.
- Externalise Merkle anchor publishing to transparency log once anchoring cadence is finalised.
| | | Array of policy rationale references (explain bundle IDs, remediation notes). |
