VEX overlay feed v3 (findings.vex-decisions)
How a tenant’s VEX decision or case-trust hold reaches release gates. Findings owns the decisions (findings.vex_decisions, the system of record under DC-32). Policy’s gate projection keeps a materialized copy of each decision’s overlay state in policy.vuln_tenant_overlay and re-derives the decided issue: a tenant override row in policy.vuln_gate_current where the decision changes the global outcome in policy.vuln_gate_global. The promotion findings lookup and the effective-status read (POST /api/policy/vex/effective-status, which the Findings read model displays) both read that projection.
Introduced by SPRINT_20260930_002 PCD-197. Producer: findings-web. Consumer: policy-engine.
1. What a decision becomes
Each decision is composed into exactly one overlay state (VexDecisionOverlayComposer). A state is never a delta: it is the decision’s complete current effect, so replaying any suffix of the stream converges.
| Decision | Effect | Overlay |
|---|---|---|
SBOM_COMPONENT subject whose purl yields a hub product key, status NOT_AFFECTED | apply | status not_affected |
same, FIXED | apply | status fixed |
same, AFFECTED_UNMITIGATED or AFFECTED_MITIGATED | apply | status affected (a mitigation is not a statement that the code is absent) |
same, UNDER_INVESTIGATION | withdraw | reason status_not_asserted: it asserts nothing and must not override an issuer |
| superseded by another decision | withdraw | reason superseded |
| scoped to environments or projects, or to one SBOM node | withdraw | reason scoped |
IMAGE, REPO or OTHER subject | withdraw | reason subject_not_component |
component purl with no hub product key (for example pkg:generic/...) | withdraw | reason product_key_underivable |
Reasons are checked in the order of the table rows from superseded down, so a decision always has one reason.
Reach. The overlay key is (tenant, vulnerability, product key). The product key comes from the hub’s own contract (ProductKeyV1.TryDeriveLookupKey) and carries no version: a decision on pkg:npm/lodash@4.17.20 applies to every lodash version for that tenant, and a Debian or Alpine decision applies to the source package. Decisions with a narrower scope are stored and shown but do not change gates (owner ruling 2026-10-04).
Validity window. validFor.notBefore / notAfter travel with the state. Policy ignores an overlay before its window opens and after it closes; its window sweep re-derives the issue at both boundaries, so an expired decision stops suppressing a CVE without a new event.
Two decisions on one issue. The later assertedAt (updatedAt, else createdAt) is the last word.
2. Producer guarantees
- Every write (
POSTandPATCH /api/findings/v1/vex-decisions) appends, in the same transaction as the decision row, one event per decision whose state the write can change: the written decision, the decision it previously superseded, and the decision it supersedes now. A write whose events cannot be appended does not persist. - Writes for one tenant are serialized (transaction-scoped advisory lock on the stream key), so sequence order is the order the states were computed in.
- Streams are per tenant on findings-web’s own P6 outbox: logical stream
findings.vex-decisions, physical keyfindings.vex-decisions.tenant.<lowercase hex of the tenant id's UTF-8 bytes>(the foldfindings.dispositionsuses). No retention is declared for the stream (decisions are rare human writes); a consumer still honoursrequiresBootstrap.
3. Routes
Both routes require the machine-only findings:projection:read scope and an authenticated tenant; they serve only that tenant’s partition. Ordinary findings:read is refused (403).
GET /api/findings/v1/ledger/vex-decision-overlays/events
Query: afterSeq (default 0), limit (1-5000, default 500), streamEpoch (the consumer’s checkpoint epoch), maxBytes (1-8388608, default 2097152).
{
"stream": "findings.vex-decisions",
"tenantId": "default",
"streamEpoch": 1791234567890,
"headSeq": 12,
"retentionHorizonSeq": 1,
"epochChanged": false,
"requiresBootstrap": false,
"envelopes": [
{ "eventId": "...", "stream": "findings.vex-decisions", "streamEpoch": 1791234567890,
"seq": 12, "type": "vex.decision.changed", "version": 3,
"occurredAt": "2026-10-04T11:30:00+00:00", "payload": { "...": "see section 4" } }
]
}
streamEpoch and headSeq are null while the tenant’s stream has never produced. requiresBootstrap is true after a producer epoch change or when the cursor is below the retention horizon.
GET /api/findings/v1/ledger/vex-decision-overlays/snapshot
Query: limit (1-2000, default 500), afterOverlayId (exclusive canonical overlay identity), streamEpoch and headSeq (together; the fence of the first page).
Returns decisions and trust rows of the tenant as overlay states, ordered ordinally by overlay id, plus the fence streamEpoch/headSeq and nextAfterOverlayId (null on the last page). Decision and trust rows with the same UUID have distinct identities. The first page creates the stream’s state row when the tenant never wrote a decision, so a fence always exists. A later page whose fence epoch is no longer current answers epochChanged: true and no overlays; a headSeq ahead of the stream head is a 400.
Pages read current decision rows, which may be newer than the fence head; that is safe because replaying the events after the fence head ends in the same state.
4. Payload (vex.decision.changed, version 3)
{
"environmentId": null,
"otherIssuerId": null,
"issuerPreferenceId": null,
"kind": "status",
"issuerId": null,
"trustOverrideId": null,
"tenantId": "tenant-a",
"overlayId": "vex-decision:6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b",
"decisionId": "6f1d2c3b-4a5e-4f60-8a7b-9c0d1e2f3a4b",
"effect": "apply",
"withdrawReason": null,
"vulnerabilityId": "CVE-2026-8100",
"productKey": "pkg:npm/target",
"status": "not_affected",
"justification": "code_not_reachable",
"validNotBefore": "2026-10-04T11:00:00+00:00",
"validNotAfter": null,
"assertedAt": "2026-10-04T11:30:00+00:00",
"supersedesDecisionId": null,
"attestationDigest": "sha256:feedface",
"subject": { "type": "SBOM_COMPONENT", "name": "pkg:npm/target@1.2.3" }
}
justification is the decision’s justification type in lower snake case. attestationDigest is the DSSE envelope digest of a signed override, else null. Both suites pin this document: the producer’s VexDecisionOverlayStreamTests and the consumer’s VexDecisionOverlayConsumerTests.
5. Consumer (Policy gate projection)
The projection host’s lease holder runs the consumer after each hub drain and on every idle poll, so the gate projection keeps one writer. It reads the stream of every tenant with a policy pack or an overlay and of every tenant Policy’s tenants replica lists as active (SPRINT_20261007_062 DIP-27). A tenant with no decision reads Policy’s system default overlay; its empty snapshot is the proof that it has none, and it is then read every ...:SystemDefaultTenantPollInterval (default 1 minute) until its first decision gives it an overlay. Per tenant:
- No checkpoint,
epochChanged,requiresBootstrapor a sequence gap: read the snapshot page by page (each page applied in its own fenced transaction), then in one final fenced transaction revoke everyvex-decision:*overlay the snapshot did not name and set the checkpoint to the snapshot’s(streamEpoch, headSeq). - Otherwise apply each event in one fenced transaction: inbox admission, overlay write, re-derive of the old and new issue, checkpoint advance.
A payload naming another tenant is an integrity fault: nothing is applied and the checkpoint does not move. A tenant whose feed fails (findings-web down, token refused) is retried after one minute; its gates keep the last applied decisions. A tenant that Policy’s local replica of Authority’s tenants catalog does not list as active is not read at all: no bearer can be issued for it, so the consumer skips it with one warning per change and bootstraps it on the first pass after the replica lists it. While the replica cannot answer (never converged, unreadable) no tenant without a checkpoint is bootstrapped; tenants that hold one keep consuming (SPRINT_20261007_064 PGF-5). The Policy Doctor check doctor.policy-engine.vex-overlays.bootstrap warns when a tenant stays without a checkpoint past ...:BootstrapWarningAfter (default 15 minutes) after host start. The tenant’s checkpoint is also the gate readers’ readiness witness: with the feed enabled, a tenant without one reads as not ready (fail closed), because the global rows alone would drop its decisions (SPRINT_20261007_064 PGF-2). Malformed or partial apply states (no product key or status) are materialized as revocations.
Configuration (policy-engine): PolicyEngine:VulnGateProjection:FindingsOverlays:Enabled (off in code; on in the shipped compose and release bundle via POLICY_VEX_DECISION_OVERLAYS_ENABLED, default true), ...:BaseAddress (required when enabled; compose http://findings.stella-ops.local), ...:Timeout (default 30 s), ...:BootstrapWarningAfter (default 15 minutes), ...:SystemDefaultTenantPollInterval (default 1 minute). The host refuses to start with the feed enabled and its Authority client disabled. The policy-engine Authority client carries findings:projection:read; on a running estate restart Authority (or reconcile the authority.clients row) after upgrading so the grant takes effect.
6. Case trust holds
CRUD at /api/findings/v1/ledger/vex-trust-overrides writes findings.vex_statement_trust_override and its overlay event in one transaction under the same stream lock as decision writes. An outbox failure rolls back the row change.
The payload has kind: "case-untrust", overlayId: "vex-trust:<uuid>", trustOverrideId: "<uuid>", null decisionId, the hub package key and status: "under_investigation". The row’s source becomes issuerId audit context. False trust applies a hold; restoring trust or deleting the row emits a withdrawal. Any live hold prevents a status assertion from clearing the issue. Multiple holds must each be withdrawn. Tenant identity remains opaque and case-sensitive.
Policy consumer policy.vuln_gate.vex_overlays_v3 snapshots the combined state on first upgrade instead of taking the decision-only checkpoint. It revokes missing vex-decision:* and vex-trust:* rows in its final fenced snapshot transaction. Known event types must carry payload version 3. The shared evaluator’s decision-hash format is also v3. Upgrade Findings and Policy together: v3 introduces issuer choices and takes a new fenced snapshot rather than interpreting the earlier retained events.
7. Confirmed issuer choices
Findings owns case confirmation and optional remembered preferences at /api/findings/v1/ledger/vex-issuer-preferences. A case confirmation names the current Policy decision hash, vulnerability, product, release environment and issuer pair. A separate remember request names that confirmed case; it never follows implicitly from confirmation. Remembered preferences require release-operation authority in addition to Findings write authority. Edits require a new explicit confirmation, and withdrawal publishes a revocation.
The v3 payload uses kind: "issuer-preference", overlayId: "vex-preference:<uuid>", issuerPreferenceId, environmentId, issuerId (preferred) and otherIssuerId. A case has vulnerability/product keys; a remembered pair has null keys and an ENVIRONMENT subject. Status/trust payloads serialize these three new optional fields as null. Snapshot pagination includes all three overlay prefixes; the final fenced transaction revokes missing preferences.
Policy keeps the base outcome and a sparse environment projection. Remembered pair changes queue bounded, resumable work in its own database; pending work makes effective-status and promotion reads unavailable for that tenant/environment. Other environments and tenants continue to answer. Once the work completes, display and promotion use the same derived choice. No whole corpus is copied for each environment.
