Policy architecture
Verified against commit
942a18727con 2026-09-26 (UTC). Re-verify:git grep -n "MapGroup(\"" -- src/Policy/StellaOps.Policy.Engine/Endpointsandgit grep -n -A2 "^ policy-engine:" -- devops/release/bundle/docker-compose.yml devops/compose/docker-compose.stella-services.yml.
The Policy module compiles and evaluates stella-dsl@1 policy packs, owns promotion-gate decisions, exceptions, risk profiles and CVSS receipts, and keeps Policy-local projections of hub advisory data for gate evaluation. It ships one deployable, policy-engine, with one database. The previous long-form dossier (design history up to 2026-09-26, not re-verified) is archived at docs-archive/modules/policy/architecture.md.
CCP-085 adds release-set verdict evidence: one signed evaluation binds the immutable authenticated component/image snapshot and policy inputs, using Evidence’s existing verified submission/archive path. Missing or incomplete inputs remain unattested, independently of the policy decision. Source qualification and later runtime acceptance are recorded separately.
1. Deployable
| Item | Value | Source |
|---|---|---|
| Host project | src/Policy/StellaOps.Policy.Engine/ (ASP.NET host with hosted workers) | Program.cs |
| Service name | policy-engine (Router consumer group policy-engine; local hostname policy-engine.stella-ops.local) | Program.cs, bundle compose |
| Release bundle | service policy-engine, image stellaops/policy-engine, network aliases policy-engine.stella-ops.local and policy-gateway.stella-ops.local | devops/release/bundle/docker-compose.yml |
| Compose estate | service policy-engine in devops/compose/docker-compose.stella-services.yml | compose file |
| OpenAPI | anonymous /openapi/v1.json; generated mirror in ../policy-engine/openapi/v1.json and ../policy-engine/api-reference.md | Program.cs, tools/scripts/docs/regen-from-openapi.ps1 |
The former StellaOps.Policy.Gateway web project was withdrawn to src/__Obsoleted/Policy/StellaOps.Policy.Gateway/ on 2026-09-27 (PCD-73, CoC 15.4); no compose service, bundle service, image or project reference had used it. Its endpoints were merged into the Engine under src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ and its services under Services/Gateway/; the policy-gateway.stella-ops.local alias resolves to policy-engine.
PCD-827: the Scanner digest-detail adapter treats a true reachability flag as proof only when it also carries a non-empty entrypoint, vulnerable symbol and path containing non-empty symbols. Historical unwitnessed true flags and false flags remain unknown for policy evaluation. This enforces the Scanner v2 proof contract without treating an abstention as a negative result.
Source verification: commit 85a783cd8f (implementation; coordinated consumer deployment pending). Re-verify from the repository root: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Method "*AggregateScannerEvidence_ReachableWithoutWitness*" -BuildProjectReferences.
Dormant-store withdrawal (DIP-36 / OD-1)
Migration 029_withdraw_dormant_policy_tables.sql removes ten stores without production writers: submitted_evidence, rules, recheck_policies, epss_thresholds, exception_applications, evaluation_runs, explanations, ledger_exports, advisory_source_conflicts and advisory_source_impacts. It also removes the unused exceptions.recheck_policy_id column. The retained exception and approval/event stores continue to belong to Policy. Applied migration history remains immutable.
The two /api/v1/advisory-sources/{sourceId}/{impact,conflicts} reads are withdrawn; the generated Policy OpenAPI is the contract. The retired repositories and the test-only legacy importer are frozen under src/__Obsoleted/Policy/. OfflineKit no longer requests exception-application data; its exception and history reader is tracked separately by PCD-871. Validate fresh, actual 028 upgrade and replay with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj -Class '*DormantPolicyWithdrawalMigrationTests*' -BuildProjectReferences.
Source verification for DIP-36: commit 394532d7114420507cb520549f9f335c9e31955d (2026-10-20). Re-verify the named native controls and generated OpenAPI; the deployment receipt is separate.
2. Source layout
| Project | Role |
|---|---|
StellaOps.Policy.Engine | host: endpoints, evaluation, gates, exceptions, simulation, workers |
StellaOps.PolicyDsl | stella-dsl@1 parser and compiler |
StellaOps.Policy.RiskProfile, StellaOps.Policy.Scoring | risk profiles; CVSS receipts and scoring |
__Libraries/StellaOps.Policy | core policy model, deltas, suppression |
__Libraries/StellaOps.Policy.Persistence | PostgreSQL data source, stores, embedded migrations |
__Libraries/StellaOps.Policy.{Contracts,Scanner.Contracts,Determinization,Exceptions,Explainability,Interop,Unknowns,AuthSignals} | contracts and domain libraries |
StellaOps.Policy.Registry | typed client for the policy registry API (IPolicyRegistryClient) |
StellaOps.Policy.Api/ | uncompiled Replay endpoint draft withdrawn to src/__Obsoleted/Policy/StellaOps.Policy.Api/ by PCD-253; see source disposition |
StellaOps.Policy.Gateway | withdrawn to src/__Obsoleted/Policy/ (PCD-73), see §1 |
3. Database
| Item | Value | Source |
|---|---|---|
| Database | stellaops_policy | PolicyPersistenceConnection.DatabaseName |
| Connection | STELLAOPS_POSTGRES_POLICY_CONNECTION, else Postgres:Policy:ConnectionString; startup fails without either | __Libraries/StellaOps.Policy.Persistence/Extensions/PolicyPersistenceConnection.cs |
| Schemas | policy, policy_app | Migrations/001_v1_policy_baseline.sql |
| Migrations | __Libraries/StellaOps.Policy.Persistence/Migrations/ (001 baseline through 029), applied on startup by AddStartupMigrations; pre-1.0 history under Migrations/_archived/ | PolicyPersistenceExtensions.cs |
| Retention | retention-classification.md; migrations 012_policy_retention_windows.sql, 013_policy_retention_classes.sql; PolicyRetentionService bound from Policy:Retention | Retention/ |
Notable forward migrations: 005_vuln_gate_projection.sql and 006_exploit_evidence_projection.sql (Policy-local projections of hub data), 007_effective_policy_bindings.sql, 010_retire_policy_sealed_mode_state.sql (installation posture moved to Platform; the table is dropped only when empty), 011_retire_unused_policy_risk_tables.sql, 024_vuln_gate_global_projection.sql (global gate rows plus tenant overrides), 026_vuln_system_default_overlay.sql (the applied system default overlay statements, section 6).
The Verdict library’s Postgres persistence retired on 2026-09-23 (RFC-14, owner ruling Q-43) is not part of this module: no Policy project references StellaOps.Verdict. The freeze record is src/__Obsoleted/__Libraries/StellaOps.Verdict/AGENTS.md.
Pack lists keep each stored parent separate, including names that differ only by case. Each parent retains its display metadata and revision numbers; equal revision numbers under different parents do not merge. The Postgres reader groups by the existing parent UUID and continues to select exact names within the owning tenant. Bundle admission and exact evaluation route/body matching follow the lifecycle and API contracts.
This list behavior was verified at source 6087e7444f247d567b4b88fa8214b4e094999e93. Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Method "*ListPacks_SeparatesStoredParentsWithSameRevisionAndTenantBoundary" -BuildProjectReferences. The disposable owner-database test uses synthetic authentication and does not establish live role or row-level security acceptance.
Pack revisions use the stored parent UUID and integer revision as their assigned natural key. The row UUID and rules/bundle digests are separate. Runtime creation binds the parent through the resolved tenant and exact name; generic persistence uses the supplied existing parent UUID. Its foreign key rejects a missing parent, and the unique tuple rejects a duplicate revision. These storage constraints do not establish authorization to use another existing parent.
The compiled import utility preserves supplied integer revisions, including zero and negative values. It skips an existing tenant/name parent before processing versions or comparing their contents. That skip preserves original content and attribution; it is not an equality-checked upsert or a claim that a public import route is registered. Both automatic allocators choose one above the greater of zero and the highest stored revision, so negative-only imports cannot make the next allocated revision unusable by the positive-only bundle route. Imported rows are unchanged.
Publication, Draft reset and bundle replacement keep the assigned tuple and original creator attribution while changing their documented publication or bundle fields. Authored source text is read back under the same tuple. The identity declaration covers the checker-recognized bundle_payload document column; source-text continuity is separately qualified. Approval arrays, AOC metadata and bundle bytes retain their payload-storage obligations. Signing, governance, history, deployed roles and live acceptance remain separate.
The assigned pack identity and positive allocation floor were verified at source b5c85b895aa7d44d43f10dee248bcde697c79f61. Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Class "*PolicyPackVersionAssignedIdentityTests" -BuildProjectReferences -Restore. These disposable owner-storage controls do not establish a public import host, trusted signing or live authorization.
Verification policies are operator-authored records keyed by the owning tenant and policy_id. Create and clone reject a blank ID; clone uses the requested new ID. Duplicate creation is refused, and updates preserve the original ID and creation time. tenant_scope is applicability metadata, so a wildcard there does not change ownership. The Postgres adapter resolves ownership from Policy’s tenant context and filters reads with it; direct calls without a resolved context still fall back to the existing default tenant. Equal complete policy JSON may be stored by different tenants and has no declared repetition bound.
This identity path was traced at 73af4fd73b in VerificationPolicyEndpoints, VerificationPolicyEditorEndpoints and PostgresVerificationPolicyStore. Re-verify the owner path with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Class "*VerificationPolicyAssignedIdentityTests" -BuildProjectReferences. These disposable-database tests isolate audit transport and use synthetic authentication; they do not establish deployed grants, row-level security or historical-row validity.
Report-policy snapshots in policy.snapshots use the unique owning-tenant/policy/revision tuple. ScannerReportPolicyPublisher binds the resolved tenant and existing Scanner report-policy ID, validates the definition and expected revision, then appends the next revision. Its row UUID and content digest are separate identifiers. SnapshotRepository retains prior rows and refuses a duplicate tuple; ScannerPolicySnapshotReader selects the latest revision within the same tenant and policy. These are distinct from the policy.engine_snapshots runtime documents. Repeating a definition creates repeated content and metadata values, whose storage obligations remain open.
For policy.engine_ledger_exports, the complete stored manifest serializes the same ExportId used as the scalar primary key. Equal complete manifests therefore cannot occupy different rows. The owner store accepts its record and line arrays independently, so this does not bound those arrays or qualify allocation, tenant access, update/no-op behavior or historical data.
Both paths were traced at 662259db88. Re-verify with the Policy Engine test project through tools/scripts/test-targeted-xunit.ps1 -BuildProjectReferences, selecting *PolicySnapshotAssignedIdentityTests and *LedgerExportManifestPayloadIdentityTests. Those tests use disposable owner storage and synthetic context; concurrent HTTP conflict mapping, deployed-role isolation and live acceptance remain separate.
Ledger exports are bound to the authenticated tenant (SPRINT_20261017_003 PCD-841):
POST /policy/ledger/exporthas no request body. It builds the export for the tenant thatIStellaOpsTenantAccessorresolves from the token, and.RequireTenant()returns 400tenant_missingwhen no tenant is resolved. No request value can select another tenant.GET /policy/ledger/export/{exportId}readsWHERE tenant_id = @tenant_id AND export_id = @export_idon a tenant connection. Another tenant’s export ID returns 404 with no body, the same answer as an unknown ID.- The save’s conflict update applies only when the stored row has the same tenant. A colliding export ID owned by another tenant is refused, and the stored row keeps its tenant and content.
policy.engine_ledger_exportshas no row-level security, so the SQL tenant predicate is the boundary.
Re-verify with the Policy Engine test project through tools/scripts/test-targeted-xunit.ps1 -BuildProjectReferences, selecting *LedgerExportTenantBindingTests (real host, disposable PostgreSQL).
Snapshots, orchestrator jobs and the worker are bound to the authenticated tenant the same way (SPRINT_20261017_003 PCD-850). Each route takes the tenant from IStellaOpsTenantAccessor with .RequireTenant():
POST /policy/snapshotsandPOST /api/policy/snapshots(oneSnapshotService): the body has notenant_id, and the snapshot is built from the caller’s tenant’s ledger export. The two list routes have notenant_idquery parameter and list only the caller’s snapshots.POST /policy/orchestrator/jobsand/preview: the body has notenant_id; the job is queued or previewed for the caller’s tenant.GET /policy/snapshots/{snapshotId},GET /api/policy/snapshots/{snapshotId},GET /policy/orchestrator/jobs/{jobId},POST /policy/worker/runandGET /policy/worker/jobs/{jobId}look the ID upWHERE tenant_id = @tenant_id AND <id> = @idon a tenant connection. Another tenant’s ID returns 404 with no body, the same answer as an unknown ID, so the worker can neither run nor read another tenant’s job.- The snapshot and job saves update a conflicting row only when it has the same tenant; a colliding ID owned by another tenant is refused. A job update cannot change the job’s tenant.
PolicyOrchestratorJobWorkerHostleases queued jobs of every tenant (the one cross-tenant job read, not reachable from a route) and executes each within the job’s own tenant.policy.engine_snapshotsandpolicy.orchestrator_jobshave no row-level security, so the SQL tenant predicate is the boundary.
Re-verify by selecting *SnapshotJobWorkerTenantBindingTests the same way.
Every other read of a persisted snapshot is bound to the caller’s tenant too (SPRINT_20261018_004 PCD-887). The snapshot store and its repository have no cross-tenant read or list: GetAsync and ListAsync both take the tenant and filter WHERE tenant_id = @tenant_id.
POST /api/policy/deltas/computereads the explicit baseline (knowledge snapshot store) and both snapshots of the delta (delta snapshot adapter) within the tenant ofIStellaOpsTenantAccessor. Another tenant’s baseline or target ID answers 404 with the same body as an unknown ID.POST /policy/violations/events,/severityand/conflictstake the tenant fromIStellaOpsTenantAccessorwith.RequireTenant();POST /policy/decisionsandGET /policy/decisions/{snapshotId}take it from the Engine’sITenantContextAccessorwithRequireTenantContext(). The Engine tenant middleware copies the first into the second, which is the tenant the violation store reads under. Each route reads the snapshot within that tenant: another tenant’s snapshot ID answers 404 with the same body as an unknown ID, and nothing is recorded for either tenant. An unknown snapshot on the violation routes is 404 (it was 400). Atenant_idin the decisions body is ignored.- The knowledge snapshot projection reads by ID, by bundle path, by digest (the tenant’s own snapshots only) and lists within the tenant. The gate target snapshot materializer reads the baseline within the tenant. The gate evaluation worker sets
IStellaOpsTenantAccessorto the queued job’s tenant, so the same binding applies there. - No default-tenant fallback: the knowledge snapshot store, the delta adapter, the gate target materializer, the gate baseline bootstrapper and the violation store refuse to run without a tenant instead of reading or writing the default tenant.
Re-verify by selecting *SnapshotReadTenantBindingTests the same way (real host, disposable PostgreSQL).
Effective-policy bindings (/api/v1/authority/effective-policies, /api/v1/authority/scope-attachments, /api/v1/authority/resolve) are bound to the authenticated tenant (SPRINT_20261018_004 PCD-881). These groups use the Engine’s ITenantContextAccessor with RequireTenantContext(), which takes the tenant from the same stellaops:tenant claim and answers 400 when no tenant is resolved:
POST /api/v1/authority/effective-policies: the body has notenant_id; the binding is stored in the caller’s tenant (an unknowntenant_idproperty in a body is ignored).- Read, update and delete by binding ID, the attachment list of a binding and detach by attachment ID filter
WHERE tenant_id = @tenant_id AND <id> = @id. Another tenant’s ID returns 404, the same answer as an unknown ID; attaching a scope to another tenant’s binding returns 400ERR_AUTH_002. An update keeps the stored tenant, and the binding upsert never setstenant_idon conflict. policy.effective_policy_bindingsandpolicy.effective_policy_scope_attachmentscarry row-level security keyed on the connection tenant in addition to the SQL predicates.
Re-verify by selecting *EffectivePolicyTenantBindingTests the same way (real host, disposable PostgreSQL; the fixture’s superuser connection bypasses row-level security, so the tests measure the SQL predicates and the route binding).
4. API surface
All families are mapped by src/Policy/StellaOps.Policy.Engine/Program.cs; route groups are declared in Endpoints/ and Endpoints/Gateway/.
| Family | Route prefixes |
|---|---|
| Policy packs, compilation, lint, bundles | /api/policy/packs, /api/v1/policy/lint, /api/v1/policy/merge-preview |
| Evaluation, batch, worker and orchestrator jobs | /policy/eval, /policy |
| Decisions, snapshots, violations, conflicts, deltas | /api/policy/snapshots, /api/policy/violations, /api/policy/conflicts, /api/policy/deltas, /api/v1/snapshots, /api/v1/policy/delta-if-present |
| Gates | /api/v1/gate, /api/v1/gates, /api/v1/policy/gate (includes POST /api/v1/policy/gate/decisions/by-digest) |
| Exceptions and approvals | /api/policy/exceptions, /api/v1/policy/exception |
| Risk profiles, overrides, simulation, scopes | /api/risk/profiles, /api/risk/profiles/export, /api/risk/overrides, /api/risk/simulation, /api/risk/scopes, /api/risk/events |
| Budgets and unknowns | /api/v1/policy/budget, /api/v1/policy/budgets, /api/v1/policy/unknowns |
| CVSS receipts | /api/cvss |
| Governance, tool lattice | /api/v1/governance, /api/v1/policy/assistant/tools |
| Authority-facing effective policies and scope attachments | /api/v1/authority, /api/v1/authority/effective-policies, /api/v1/authority/scope-attachments |
| Attestation reports and verification policies | /api/v1/attestor/reports, /api/v1/attestor/policies, /api/v1/attestor/policies/editor, /policy/console/attestation, /api/v1/verify |
| Compliance | /api/policy/control-register, /api/policy/compliance/cra/technical-documentation/provenance |
| Scanner report snapshots (Scanner policy-owner seam) | /api/policy/scanner-report-snapshots |
| Effective VEX status (Findings owner read) | POST /api/policy/vex/effective-status |
| Advisory sources, air-gap | /api/v1/advisory-sources, /api/v1/airgap/bundles, /api/v1/airgap/risk-profiles, /system/airgap, /system/airgap/notifications, /system/airgap/staleness |
| Ledger export (tenant-bound) | /policy/ledger/export |
| Console export jobs and bundles | /api/v1/export |
| Registry webhooks | /api/v1/webhooks/registry (/docker, /harbor, /generic) |
Endpoints/DeterminizationConfigEndpoints.cs declares /api/v1/policy/config/determinization, but Program.cs does not call MapDeterminizationConfigEndpoints, so that family is not served (Determinization API, section 12; verified against 02993ecedd, re-verify: git grep -n MapDeterminizationConfigEndpoints -- src).
The by-digest gate-decision read was added by SPRINT_20260913_001 ORP-3 so that integrations-web reads Policy decisions through Policy’s API instead of opening Policy’s database (Contracts/Gateway/GateDecisionByDigestContracts.cs, Endpoints/Gateway/GateEndpoints.cs).
The effective VEX status read (Endpoints/VexEffectiveStatusEndpoints.cs, SPRINT_20260930_002 PCD-196) answers, for the caller’s tenant (taken from the token), up to 500 (vulnerabilityId, productKey) pairs from the composed gate projection (policy.vuln_gate_effective; the unscoped row, not a release row): status, justification, winning issuers, contested, overlayApplied and the decision hash. Pairs with no VEX statement are omitted only after both projection readiness and the tenant’s inventory coverage are verified. An unavailable batch answers 503 with a cause-specific Problem Details title: vex_projection_not_ready means the tenant or environment’s projection readiness conditions are unmet; vex_inventory_uncovered means the requested inputs lack complete owner-inventory coverage for this tenant. A tenant with a converged overlay checkpoint and no admitted inventory remains unavailable for those inputs. The response identifies missing coverage without claiming the projection has never consumed a hub event. See Readiness under the gate projection storage model and inventory coverage. The batch is read pair by pair: each pair becomes key predicates in the environment, override and global branches, so the global table is read by its primary key and the read’s cost follows the batch size, not the projection size (see the Gate projection storage model). It requires policy:read. Findings reads it with its internal stellaops-findings-security-internal identity to show each finding’s VEX outcome, so the console and the release gate read one effective status.
Exception object tenancy
A Policy exception object (/api/policy/exceptions, Endpoints/Gateway/ExceptionEndpoints.cs) belongs to the tenant that creates it (SPRINT_20261007_028 CCP-069):
- Create stores the tenant of the authenticated request (the
stellaops:tenantclaim) inpolicy.exceptions.tenant_id. An exception that the gate-level approval workflow materializes is stored under the approval request’s tenant. The repository refuses a create without a tenant (ArgumentException) instead of storingdefault. - The list, counts, by-id, history and expiring-soon reads return only the caller’s tenant’s rows: an explicit
tenant_idpredicate, with the same tenant as the database session tenant. Update, approve, activate, extend and revoke look the exception up the same way. Another tenant’s exception id answers 404, as an unknown id does; forGET /api/policy/exceptions/{id}/historyan unknown id previously answered 200 with an empty event list. - The expiry worker’s sweep and its expiring-soon warnings read every tenant (
GetExpiredActiveAsync(),GetExpiringAsync(horizon)) in a database session as tenantdefault; the lifecycle worker reads each configured tenant’s lapsed rows in that tenant’s session (GetExpiredActiveAsync(tenantId)). A lifecycle update, an expiry included, binds by the globally uniqueexception_idand opens its session as the scope’s UUID tenant, or asdefaultwhen the stored tenant is not a UUID. These paths depend on the policy-engine role not being restricted by row-level security onpolicy.exceptions: it is enabled but not forced (001_v1_policy_baseline.sql), so it does not restrict the table owner. Forcing it would hide every row whose tenant is notdefaultfrom the every-tenant sweep and the expiring-soon warnings (their session tenant isdefault, and the policy compares the session tenant with each row’stenant_id), and would stop the expiry and every other update of a row whose tenant is neitherdefaultnor a UUID. - Rows stored under tenant
defaultbefore this change are not reassigned. Until then every exception created through the API or the approval workflow was stored underdefault, whatever the creating tenant; such a row is visible only to a caller whose tenant isdefault. Moving one to its creating tenant is an operator decision: a row created through the API does not record that tenant (a workflow-materialized row carries it inmetadata.tenantId). ExceptionScope.TenantIdremains a nullable UUID and cannot hold a text tenant. The owning tenant is passed to the repository separately (IExceptionRepository.CreateAsync(tenantId, ...)), and reading a text-tenant row leaves that member null.
Exception object expiry
An exception object stops being effective at its expires_at instant (ExceptionObject.IsEffectiveAt). From that instant the approve, activate and extend routes answer 400, and ExceptionService (registered, no caller) refuses the same transitions, whether or not a sweep has stored the object as expired. The approval workflow refuses an approval at or after the request’s exceptionExpiresAt (SPRINT_20261007_028 CCP-052).
The stored status moves to expired through one audited transition, ExceptionExpiryWorker.ExpireAsync (SPRINT_20261007_028 CCP-070): the next version and one Expired history event by system:expiry-worker, written in one repository transaction behind the version check. Two hosted workers apply it:
| Worker | Reads | Cadence |
|---|---|---|
ExceptionExpiryWorker | GetExpiredActiveAsync(), every tenant | Policy:Exceptions:Expiry:Interval, default 1 hour |
ExceptionLifecycleWorker | GetExpiredActiveAsync(tenantId) for each tenant in PolicyEngine:ResourceServer:RequiredTenants; nothing when none is configured | PolicyEngine:ExceptionLifecycle:PollIntervalSeconds, default 60 seconds |
When both workers reach the same object, the version check lets one of them write it, so the object records one Expired event. No other path writes expired: the per-tenant UPDATE ... SET status = 'expired' of ExceptionRepository.ExpireAsync, which recorded neither a version nor an event, was removed. The schema function policy.mark_expired_exceptions() (001_v1_policy_baseline.sql), which writes the same transition for every tenant with actor system at the database clock, has no caller. Tests: ExceptionLifecycleServiceTests and ExceptionExpiryAcceptanceTests (Policy Engine tests, PostgreSQL).
Registry webhook signature contract
The registry-webhook group (Endpoints/Gateway/RegistryWebhookEndpoints.cs) authenticates the sender with a per-registry HMAC-SHA256 shared secret, not with a bearer token. The group is AllowAnonymous() for authentication, then runs RegistryWebhookSignatureFilter and RequireTenant() in that order. The filter answers 401 (problem+json, detail = a reason token) when the kind is not enabled, its secret is not resolvable, or the signature is missing, malformed or wrong. Only a correctly signed request reaches the handler, which answers 202 with the queued job ids.
- Registration:
Program.cscallsAddRegistryWebhookSignatureValidationandUseRegistryWebhookBodyBuffering(source underWebhooks/). The buffering middleware lets the filter read the raw body before model binding. Requests that arrive through the Router bridge skip HTTP middleware but carry a seekable body, and the filter still runs because it is part of the endpoint delegate. - Signature headers: Docker
X-StellaOps-Signature: sha256=<hex>, HarborX-Harbor-Signature: sha256=<hex>, genericX-StellaOps-Signature: sha256=<hex>with an optionalX-StellaOps-Key-Idchecked againstAllowedKeyIds. HMAC runs throughIHmacAlgorithm, so a regional crypto plug-in registered earlier wins over the default. - Configuration: the options section is
Policy:Gateway:RegistryWebhooks. It keeps its historical name because the release bundle and the compose source already setPolicy__Gateway__RegistryWebhooks__{Docker,Harbor,Generic}__Enabledand__SharedSecret__EnvVaron thepolicy-engineservice, with the secret variablesPOLICY_REGISTRY_WEBHOOK_{DOCKER,HARBOR,GENERIC}_SECRET. Every kind is disabled by default, and a disabled kind refuses all requests. - Tenant:
RequireTenant()and the tenant middleware resolve tenant only from thestellaops:tenantclaim of an authenticated principal. A sender therefore needs a token with that claim in addition to the signature; an anonymous request is refused with 400 (POLICY_TENANT_HEADER_REQUIRED) before the signature is checked. Send the token to the Router gateway: it validates the token, forwards the tenant in the identity envelope and stripsAuthorization. The Docker validator’sAuthorization: Basic <user>:<hex>form cannot share that header with the token, and it refusesAuthorization: Beareron a direct connection, so Docker senders use theX-StellaOps-Signatureheader.
Operator steps and sender recipes: docs/operations/policy-registry-webhooks.md.
5. Background work
Hosted services registered by Program.cs directly or through its registration extensions: ExceptionLifecycleWorker, ExceptionExpiryWorker, IncidentModeExpirationWorker, PolicyEngineBootstrapWorker, RiskOverrideHydrationService, VulnGateProjectionWorker (always hosted; logs when the projection is disabled), PolicyOrchestratorJobWorkerHost, GateEvaluationJobWorker (AddPolicyAsyncGateEvaluationRuntime; the queue answers 501 when Policy storage is not configured) and PolicyRetentionService (AddPolicyRetention). Queued policy evaluation runs through PolicyOrchestratorJobWorkerHost: POST /policy/orchestrator/jobs queues a job, the host executes it with PolicyWorkerService, and GET /policy/worker/jobs/{jobId} returns the stored result. The earlier in-process re-evaluation worker (PolicyEvaluationWorkerHost, AddPolicyEngineWorker and the PolicyEngine:Workers options) was never hosted, had no job producer and evaluated nothing; it was withdrawn on 2026-09-27 and the PolicyEngine:Workers section is no longer read.
6. Inbound data and integrations
| Integration | Mechanism | Configuration |
|---|---|---|
| Vulnerabilities hub advisory data for gates | VulnGateProjectionWorker / VulnGateProjectionHost fill the gate projection (policy.vuln_gate_global plus tenant overrides in policy.vuln_gate_current) and the exploit-evidence projection from hub section streams over the hub API; off in code; the compose stack and release bundle turn it on (POLICY_VULN_GATE_PROJECTION_ENABLED, default true) | PolicyEngine:VulnGateProjection, PolicyEngine:VulnGateProjection:Hub, :Trust |
| Release-time findings lookup | PostgresFindingsLookup reads only the gate projection, through policy.vuln_gate_effective; release components and Scanner digest detail arrive through owner clients | PolicyEngine:FindingsLookup, PolicyEngine:ReleaseComponents, PolicyEngine:ScannerDigestDetail |
| Unknowns gate | HTTP client to Unknowns | Policy:UnknownsGate |
| Tenant catalog | local replica of Authority’s tenant catalog (AddStellaOpsTenantResolverReplicaOnly) | Catalog:Replication:Tenants |
| Installation posture | read from Platform’s catalog replica after migration 010 | — |
| Operator signing enrollment | PlatformOperatorSigningEnrollmentPolicyClient | Policy:OperatorSigningRequirement, Policy:RiskOverrideSigning, Policy:ExceptionApprovalSigning |
| Audit | audit emission to Timeline | AuditEmission |
| Router and messaging | Router messaging transport and a single process-wide cache factory (PolicyMessagingComposition); the bundle mounts the signed base/Valkey messaging plugin | Router, Messaging |
Reachability recompute boundary. Policy’s Signals integration reads published facts through IReachabilityFactsSignalsClient and SignalsBackedReachabilityFactsStore. Recompute belongs to the Signals owner API and requires a real callgraphId, subject, entryPoints and targets. The unused subject-key-only Policy forwarding method and request type were removed during the Services archive carry; its fact-read methods are preserved. Verified against 67479ffa9a. Re-verify with the Policy Engine *ReachabilityFacts* test classes and a production symbol census for TriggerRecomputeAsync and SignalsRecomputeRequest (zero matches).
Projection match key. The projector extracts package_name through PurlComponentExtractor: native package names such as openssl, busybox and vim, and namespaced language coordinates. The indexed read also accepts product-key path forms such as debian/openssl, alpine/busybox and redhat/vim?arch=src. The findings lookup selects candidate rows by the lower-cased last / or : segment of package_name, with qualifiers and subpath removed, through the expression index idx_vuln_gate_global_leaf (migration 024_vuln_gate_global_projection.sql) and, for tenant overrides, idx_vuln_gate_current_tenant_leaf (migration 014_vuln_gate_current_leaf_name_index.sql). It then names each candidate from its product key with the same extractor. For scanned and PURL-curated native OS packages it first checks the producer-owned ProductKeyV1 identity: deb/apk source package and distribution, or RPM binary name, vendor, architecture and nonzero epoch. It keeps each identity paired with the release of its own image, including when equal short names and versions occur in multiple images. EcosystemAwareJoinPlan and the existing version filter then decide the match; language-package coordinate aliases retain their existing semantics.
Generic component PURLs derive no hub product key. They remain Scanner evidence, but a short name cannot join them to issuer namespaces such as pkg:generic/suse-csaf/openssl. If any successful, nonempty image scan contains only PURLs without a hub-addressable identity, Policy reports its findings source unavailable. A measured empty scan can still use valid curated package inputs. Unknown versions within a matching native identity keep the existing fail-closed version handling. A curated component without a PURL retains the existing name-based contract.
Verified against clean source aaeae39e5c568b7dca4aa133cbb8c5f49183e6e7, adopted on 2026-10-20. The real release controls allow patched Noble and deny affected Bookworm for the same CVE; both resolve actual findings and are uncached. Native acceptance and limits. Re-verify the source contract with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Class "*PostgresFindingsLookupProductIdentityTests" -BuildProjectReferences.
The first startup on an existing estate builds that index in one statement, and the engine answers no request until its startup migrations finish. On a 22-million-row projection the build took 137 s on an idle host (the index expression alone about three minutes on a loaded one), close to the migration host’s 300 s default. Policy’s startup migrations therefore run with a 1,800 s per-migration timeout (PolicyPersistenceExtensions.StartupMigrationTimeoutSeconds), because a cancelled migration rolls back and fails every startup. Building an index gathers no statistics for its expression, so migration 015 then analyzes the table once; without those statistics the planner overestimated a leaf read by three orders of magnitude and spent about 0.5 s on JIT compilation for an 11 ms read.
Gate projection storage model (SPRINT_20261007_064 PGF-2, migration 024_vuln_gate_global_projection.sql). The decision is stored once for every tenant, with tenant rows only where a tenant changes it (DC-38 item 4):
| Table | Holds | Row exists when |
|---|---|---|
policy.vuln_gate_global | one shared consensus outcome per (vulnerability_id, product_key, image_platform, fips_install), evaluated with the configured installation default and without tenant overlays; no tenant column or row-level security | an inventory-relevant issue with inputs, or an exact configured default-only issue; a repeated release scope is folded into its unscoped global row (PGF-4) |
policy.vuln_gate_current | a tenant’s override row; tenant row-level security, forced | the tenant’s overlays change an outcome column (status, justification, severity, affected range, winning issuers, contested, overlay_applied, confirmation fields), or decide an issue with no global row |
policy.vuln_gate_environment_current | an issuer-preference row for one tenant and release environment (migration 019) | an environment preference names two issuers present in the scope |
policy.vuln_system_default_overlay | per issue key, the system default overlay statement the gate rows carry, with its reason, statement hash and the time it was first folded (migration 026); no tenant column, no row-level security | the issue’s rows were last derived with a statement |
“Identical to the global row” is judged on those outcome columns, never on decision_hash: the hash covers the overlay list, so one tenant-wide trust overlay would otherwise copy every row of the tenant. A tenant without a live overlay stores no row. Every reader goes through one composed read, policy.vuln_gate_effective(tenant, environment): the environment row, else the tenant override, else the global row, each replacing the whole row (no per-column fallback). It is an inlinable SQL function, so the readers’ leaf, key and scope predicates reach idx_vuln_gate_global_leaf and idx_vuln_gate_current_tenant_leaf. Only restriction predicates reach the branches that way. The override and global branches keep their NOT EXISTS anti-joins, so the planner keeps them as subqueries, and PostgreSQL never pushes a join clause into a subquery: a set of keys joined to the composed read becomes a scan of the whole global table hash-joined to the keys (48.6 s for 500 keys on a 36-million-row table, SPRINT_20261007_064 PGF-8). A reader that resolves a set of keys therefore joins the keys LATERAL into the composed read, in a subquery fenced with OFFSET 0 so the planner cannot flatten it back into that join. Each key then becomes restriction predicates in every branch, so each table can be read by its key index (vuln_gate_global_pkey, vuln_gate_current_pkey, ix_vuln_gate_environment_current_key). The planner still chooses per table: a tenant table of a few rows may be scanned sequentially once per key, which is the cheaper plan at that size, while the global table is read by its primary key. The effective VEX status batch read is such a reader. A non-overridden issue serves the global decision_hash; the Findings issuer-confirmation staleness check compares two values of the same read, so it stays consistent.
The release findings lookup and first-baseline enrollment use the same fenced shape per distinct package-name leaf (SPRINT_20261007_064 PGF-9). A single leaf = ANY(batch) restriction can still select a global sequential scan when the batch contains frequent kernel-package leaves: the measured 300-leaf baseline reads took 193.7 s and 248.5 s on the estate. Each leaf now enters the composed read as an equality inside CROSS JOIN LATERAL, with OFFSET 0 preventing flattening. The ecosystem condition stays inside the leaf read; baseline enrollment also keeps image_platform = '' AND NOT fips_install inside it. Findings keeps its existing release/status filter and case-hold columns. Distinct leaves preserve ANY’s set semantics. No new index is required, and the environment > tenant override > global whole-row precedence and tenant RLS remain in the composed function.
| Composed-read consumer | Access restriction and path |
|---|---|
PostgresGateProjectionAdvisoryReader.ScopedSql / LegacySql | Fenced per-leaf equality through idx_vuln_gate_global_leaf; unscoped non-FIPS rows; optional ecosystem restriction; ordered by vulnerability and product |
PostgresFindingsLookup.ScopedProjectionSql / LegacyProjectionSql | Fenced per-leaf equality through idx_vuln_gate_global_leaf; optional ecosystem restriction; release scopes retained; existing effective-status filtering |
PostgresVexEffectiveStatusReader.ReadSql | Fenced per-key equality through vuln_gate_global_pkey; unscoped non-FIPS rows |
PolicyWorkerService projection reader, ReadAsync / ReadAdvisoryRowsAsync | Full-key or vulnerability-prefix restriction through the gate key indexes; unscoped non-FIPS rows |
VulnGateProjector.ReadLinksetHashAsync | Full-key restriction through the gate key indexes; unscoped non-FIPS row |
VulnGateProjector.ReadAffectedIssuesAsync, tenant-wide overlay | Intentional full unscoped issue enumeration: a tenant-wide overlay can change every global issue and every override-only issue |
Small tenant and environment layers can use sequential scans when cheaper; the global leaf path is pinned under default planner settings by GateLeafBatchReadTests on 100,000 global rows with twelve frequent leaves. Verified against source commit 59156d1454. Re-verify with that class and the production vuln_gate_effective( consumer census under src/Policy.
The projector derives each relevant issue once for the global rows and its registered tenant inventory from one input read. The event drain, indexed backfill, an overlay apply, the window sweep and the environment re-derive use the same relevant union and evaluator. Every tenant’s overlays for the issue are read in one round trip, each under its own tenant context, and the writes go in a second. Inventory can be registered without a policy pack; a database with no registered inventory consumes proven irrelevant events without storing a full corpus. A tenant-wide overlay re-derives every relevant issue the tenant reads (the global keys and its own override-only keys); the environment re-derive pages candidate keys from the global table’s issuer GIN index.
The event drain resolves an event’s owner inputs once, before opening the destination transaction, and reuses that result and generation for the global row and every tenant (PCD-347). Tenant overlays remain local reads inside the fenced transaction. Inbox admission, decision writes and checkpoint advancement still commit or roll back together. The lookup precedes admission, so a redelivered event can perform a lookup even when the inbox later rejects its duplicate; it does not repeat destination writes. Overlay-only re-derivation keeps its existing separate input path.
Readiness. Each reader checks the ready relevance lifecycle and the requesting tenant’s complete selector/source coverage in the same repeatable-read snapshot as its projection query. The gate consumer (policy.vuln_gate) must have a checkpoint on vuln.consensus in that actual epoch, with no recovery fence, and, with PolicyEngine:VulnGateProjection:FindingsOverlays:Enabled on, the decision-overlay consumer (policy.vuln_gate.vex_overlays_v3) has a checkpoint on that tenant’s stream, i.e. its overlays were consumed at least up to a snapshot (VulnGateReadiness). Serving the global rows to a tenant whose overlays were never consumed would drop an affected decision, which fails open; the lookup reports the source unavailable instead. With the overlay feed off there is no overlay checkpoint to await. A newly registered tenant still earns its own inventory coverage, even if another tenant already populated the shared rows. Old per-tenant corpus seed markers remain retired. A tenant with no decision of its own still consumes an empty overlay snapshot. Every reader also requires its exact default configuration marker, or no marker and no recorded statement when configured without defaults; a differing replica remains unavailable.
System default overlay (SPRINT_20261007_062 DIP-27, owner ruling Q-53). The installation may configure VEX status statements that every tenant reads by default, PolicyEngine:VulnGateProjection:SystemDefaultOverlay:Statements: at most one per issue key (vulnerability id and versionless hub product key, the key Findings gives a tenant decision), each with status affected, fixed or not_affected, an optional justification and a required reason. A duplicate key, a versioned or non-hub product key, another status, a missing reason or more than 1,000 statements refuse start. None is configured by default. The projector evaluates every global row with the issue’s statement, so the global rows are what a tenant without an override reads, and a statement on an issue no issuer spoke about stores a global row of its own. Precedence is per key: a tenant’s live status decision on the same key replaces the statement for that tenant only, a withdrawn or expired decision falls back to it, and a tenant case hold still keeps it from clearing an issue. Tenant override rows are still written only where the outcome differs from the global row, so no tenant holds a copy of the default. Every derivation (event drain, certified relevance backfill, overlay apply, window sweep, environment re-derive and the default apply) records per issue key the statement its rows carry in policy.vuln_system_default_overlay, with its reason and statement hash, or removes the key’s record when they carry none; a change of that record removes the marker, so rows derived under a configuration other than the applied one (for example while the apply keeps failing after a restart) leave no reader ready. The lease holder applies the configuration on each pass (one existence read when nothing changed). With the marker of another configuration in place it re-derives the keys whose statement was added, changed or removed, compared by statement hash; without any marker (first apply, rebuild, or a derivation that changed the record) it re-derives every recorded and every configured key. Both run in one fenced transaction over the global rows and every active tenant’s overrides, then move the marker to the configuration fingerprint (or remove it when no default is configured) and log the keys. It keeps running while the capacity gate is closed, because it follows installation configuration, not the corpus. While the gate consumer has no checkpoint only the marker is forgotten; the record is kept, so a rebuild keeps a reader without a default unavailable until the default apply has re-derived every recorded and configured key. Common relevance reset invalidates the marker in the same fenced transaction as the gate checkpoint for recovery, source and trust/platform changes. Global clear batches also revoke it, including an initial migration027 clear. Indexed owner and tenant-overlay pages do not discover a default-only key: the independent installation apply restores that exact key globally without manufacturing tenant inventory or coverage. Configured default rows survive global pruning; a removed record is retired with its obsolete rows. If post-hydration validation starts another recovery inside the apply, no completion marker is published over that clear. Readers stay unavailable until rows, records and the marker commit, in addition to their actual coverage and tenant-overlay fences. The default holds installation configuration only, never a tenant overlay. Limitation: policy.vuln_gate_effective reports a row decided by a default statement as decision_source = 'global' with overlay_applied = false.
Overlay bootstrap (SPRINT_20261007_064 PGF-5). The decision-overlay consumer bootstraps a tenant only when Policy’s local replica of Authority’s tenants catalog (catalog_replica.replica_rows, catalog tenants) lists it with status active, the rule Policy’s request-path tenant resolver applies. An active Policy tenant that the replica does not list (not an Authority tenant, or deleted) or lists with another status (for example suspended) cannot be issued the per-tenant findings:projection:read bearer, so the consumer skips it: no feed call, no one-minute backoff, one warning per change of the tenant’s standing, and a bootstrap on the first pass after the replica lists it as active. Its gates stay unavailable meanwhile. The replica answers only after its drain checkpointed (catalog.replica.tenants); until then, or when it cannot be read, no tenant without an overlay checkpoint is bootstrapped (fail closed, one warning per change) and tenants that already hold one keep consuming. On a fresh install the gates therefore read unavailable until the tenants replica converges and the next consumer pass bootstraps each listed tenant. The Doctor check doctor.policy-engine.vex-overlays.bootstrap (section 8) reports these states. Policy has no purge path for a tenant’s pack rows: an active tenant that will never exist in Authority stays skipped until an operator removes its packs or creates the tenant.
The consumer reads the stream of every tenant with a policy pack or an overlay row and of every tenant the replica lists as active (SPRINT_20261007_062 DIP-27). Policy cannot know that a tenant has no decision without reading its stream (Findings is the system of record), so a tenant without a pack is bootstrapped too; its empty snapshot is the proof that it reads the system default overlay. Such a tenant is then read every PolicyEngine:VulnGateProjection:FindingsOverlays:SystemDefaultTenantPollInterval (default 1 minute) instead of every pass, so its first decision applies within that interval; from then on it holds an overlay row and is read every pass. While the replica cannot answer, the tenants that hold a checkpoint stand in for its listing and keep being consumed. The derivation tenant set is unchanged (pack and overlay tenants): a tenant on the default stores no rows.
Rebuild. The tables are derived and rebuildable. Migration 024 truncated the per-tenant copies (on the dev estate 60.4M rows / 47.1 GB, three identical copies of one 20.0M-row set; PGF-1 memo) and deleted the gate checkpoint, the platform-scopes and issuer-trust markers and the seed markers, so the projection’s first-start path rebuilt both tables. PGF-4 migration 027 replaces that full-corpus bootstrap with one resumable authenticated-inventory and indexed-owner lifecycle. It fences readers before clearing derived rows, backfills the relevant union, and derives relevant overlay-only issues before certifying completion. Decision-overlay checkpoints and policy.vuln_tenant_overlay are kept. Mounted generations cannot certify inventory coverage. The PGF-1 memo expected one tenant set, 19,992,272 rows and about 13.2 GB. Measured on the dev estate after the rebuild (2026-10-11): 35.56M rows and 23.04 GB, of which 19.80M unscoped rows and 15.85M release rows, above the 5M-row / 4 GB design budget. Since migration 025 a release row that repeats the unscoped outcome is folded into the unscoped row (Release-scoped rows); SPRINT_20261007_064 PGF-4 owns the residual. The settings hash left both tenant tables in the same migration: it hashed {tenant, pack version}, was no evaluator input and had no reader.
The storage model is source-verified against c80d87dfee (2026-10-08). Re-verify: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj -Class "StellaOps.Policy.Persistence.Tests.GateProjectionContractTests" -BuildProjectReferences and the Engine PostgresFindingsLookupTenantReadinessTests and VexEffectiveStatusReaderTests classes. The size after migration 025 is measured on the dev estate after its deploy and re-derive.
PCD-334 carries the inclusive lastAffected bound through both owner lookup and corpus input paths into the winning consensus ranges and effective_affected_range JSON. The matcher SDK compares it with <=, alongside any exclusive declared fixed bound. This preserves the last vulnerable version without inventing a safe version. Consensus input publications use section format v4; consumer adoption precedes the Worker re-normalization rollout. Mounted-corpus trust re-derivation retains the same inclusive bound when streaming its inputs. The direct owner stream validates the consensus-inputs section’s own current/predecessor format window, rather than the independent exploit/reachability window. Current consensus bytes retain lastAffected and exclusive introducedExclusive; older and newer unsupported section profiles refuse the stream before parsing.
PCD-445 retains nullable declared comparisonScheme in effective ranges independently of ecosystem identity. The Worker and lookup use the SDK’s typed outcome: outside proves not_affected, supported inside proves affected, and unsupported declared comparison stays under_investigation/unknown. Zero-floor shortcuts cannot soften explicit generic ranges or literal exact claims. Tenant VEX overlays and the consensus decision profile remain v3. Legacy OSV generic-exact does not authorize new literal metadata: the retained shape conflates enumerated versions and ECOSYSTEM events. Its existing endpoint JSON and ecosystem dispatch remain until the separately owned producer correction supplies trustworthy shape.
PCD-445 source verified against
56357b8682799c029045a161491eabab422373e8(2026-10-08 UTC). Re-verify:pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -All -BuildProjectReferencesand repeat withsrc/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj.
PCD-444 source verified against
87feffb908(2026-10-07 UTC). Re-verify:pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj -Class "*HubLookupConsensusInputSourceTests" -BuildProjectReferences.
PCD-334 source verified against
6d3401cdc5(2026-10-06). Re-verify:pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj -Method "*LastAffected*" -BuildProjectReferences -MaxThreads 1.
Release-scoped rows. A distro feed states a fix per release, so one decision per issue evaluated over every release unions their ranges: a Debian bookworm package fixed at 3.0.13-1~deb12u1 still fell inside trixie’s [,3.1.5-1) and blocked the release. Hub facts carry the release they apply to (Applicability.Platform, grammar <os-release ID>:<release>[:<stream>], DistroPlatformV1 in StellaOps.Vulnerabilities.Contracts), and migration 016_vuln_gate_current_platform_scope.sql adds image_platform and fips_install to the primary key of the projection (since migration 024 the key of policy.vuln_gate_global and of the tenant overrides). Per issue the projector derives (and stores, except the folded release rows below):
image_platform | fips_install | Inputs | Judges |
|---|---|---|---|
'' | false | every statement | a component whose release is unknown, and every issue no distro fact scopes |
a release a fact names (debian:bookworm, rhel:9) | false, or true for Ubuntu FIPS-stream facts | the statements that apply to that release (DistroPlatformV1.Applies) | a component installed from that release |
'*' | false | the statements without a release, written only when release rows exist | a component from a release no fact names |
Red Hat minor-release and Extended Life Support streams open no row, because no image release selects them. The findings lookup takes the release of each component from Scanner’s digest detail (imagePlatform, read from the image’s os-release) through DistroPlatformV1.ComponentPlatform. For an issue with release rows it reads them all and judges each component by exactly one: its release’s row, else '*', else none (no statement covers it), or '' when the release is unknown. A suppressed release row (not_affected, or fixed without a range) therefore clears its components instead of handing them to another row. A component that several images install from different releases is judged once per release. The worker status reads, the baseline-scan enrollment and the effective VEX status read keep reading the '' row.
The relevant backfill derives all applicable release/FIPS scopes and writes policy.vuln_gate#platform-scopes-v1 with its completion checkpoint. A missing marker or changed issuer-trust configuration re-enters that same resumable lifecycle; it does not launch a separate whole-corpus section walk. Readers remain fenced while required coverage is incomplete.
Folded release scopes (SPRINT_20261007_064 PGF-4, retained within the settled full relevance filter; migration 025_vuln_gate_folded_release_scopes.sql). On the dev estate about 70% of the release rows repeated their issue’s unscoped outcome column for column (2026-10-11: 15.85M release rows beside 19.80M unscoped rows); the rest differ almost only in the affected range a distro states per release. The projector therefore stores a global release row (a named release or '*') only when an outcome column differs from the unscoped row, by the rule that decides whether a tenant row repeats the global row (VulnGateProjector.SameOutcome: status, justification, severity, affected range, winning issuers, contested, the confirmation fields). Every other release scope is recorded on the unscoped row, in folded_platforms and folded_fips (the same position in both arrays, NULL when nothing is folded; a check constraint keeps them on the unscoped row and paired). policy.vuln_gate_effective expands each folded scope back into a row under its own key, with the global unscoped row’s columns, so every reader sees the keys and outcome columns it saw before and no reader changed: the findings lookup still judges a component by its release’s row, else '*', else none. The expansion reads the global unscoped row, never a tenant’s override of it, and the override or environment row of the folded scope’s own key still replaces the expanded row; a tenant override is still compared with the global derivation of the same scope. An expanded row carries the unscoped row’s input_hash, decision_hash, issuer_ids and derived_at; no reader reads those on a release row. The global upsert’s content guard also rewrites a row whose folded scopes changed with an unchanged decision. Migration 025 deletes the platform-scopes marker, so the re-derive above walks the corpus once and folds an existing projection while the gate checkpoint, and with it gate readiness, stays: an issue not yet re-derived keeps its release rows and the composed read returns the same rows for both shapes. The space of the deleted rows is reused by later writes; the table file shrinks only when the table is rewritten. Expected size from a read-only sample (2026-10-12): about 25.2M rows and about 16 GB after a rewrite, still above the 5M-row / 4 GB design budget. An engine image older than 025 still starts on a migrated database (the migration runner ignores an applied migration it does not embed), but it neither writes nor clears the folded columns while the 025 function keeps expanding them: the release rows it stores again sit beside expanded rows of the same key, and a release whose distro fact disappeared keeps a stale expanded row with the unscoped outcome. Recover by rolling forward and deleting the platform-scopes marker, not by running the older image.
The folded shape is source-verified against aa5efef5ec (2026-10-12). Re-verify: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj -Method "*Fold*" -BuildProjectReferences and the Engine DistroPlatformGateTests class.
Tenant VEX decisions (SPRINT_20260930_002 PCD-197). Findings owns tenant VEX decisions and publishes each decision’s overlay state on a per-tenant findings.vex-decisions feed (contract). When PolicyEngine:VulnGateProjection:FindingsOverlays:Enabled is on, the projection host’s lease holder runs VexDecisionOverlayConsumer on lease acquisition, after each non-empty hub drain batch, and on every idle poll. Certified relevance convergence runs before the drain. The same lease serializes the hub and decision writes, so a sustained backlog cannot defer a new tenant’s readiness checkpoint or an existing tenant’s decision until idle. The scheduling bound is one committed hub batch plus one overlay pass; it is not a fixed wall-clock promise, since owner-feed latency, tenant feed backoff and snapshot size still apply. Shared readiness also requires the certified relevance fence; this schedule does not bypass it. Each overlay pass returns to the hub drain before another batch. Scheduling source verified against 3eac5c1554; re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj -Method "*DuringBacklog_AndContinuesDraining*" -BuildProjectReferences.
For each tenant it consumes (pack and overlay tenants, and every tenant the tenants replica lists as active; see Overlay bootstrap above) it reads the feed with a per-tenant findings:projection:read bearer of the policy-engine client and applies each event in one fenced transaction (inbox admission, overlay write into policy.vuln_tenant_overlay, re-derive of the issue the overlay left and the issue it decides, checkpoint advance). First start, a producer epoch change, a retention gap or a sequence gap rebuild the tenant from the fenced snapshot and revoke the decision overlays it no longer names. An event for another tenant is refused without advancing; a tenant whose feed fails is retried after one minute and its gates keep the last applied decisions. A tenant the tenants replica does not list as active is skipped rather than retried (see Overlay bootstrap above). An overlay row carries asserted_at, valid_from, valid_until and window_state (migration 017): between two live status overlays on one issue the later assertion is the last word, an overlay outside its window is ignored, and the window sweep (VulnGateProjector.ApplyOverlayWindowTransitionsAsync, every pass) re-derives an issue when its overlay’s window opens or closes. A status overlay decides an issue even when no issuer has a statement for it (the shared evaluator honours a lone status overlay). Decisions apply to the whole package for the tenant; the overlay key carries no version, and the decision holds in every row of its issue ('', each release row and '*'). Case trust rows now travel on the v2 feed as case-untrust holds. A live hold forces under_investigation even when a later status assertion would clear the issue; restoring trust or deleting that row releases it. The optional issuer is audit context. Migration 018 admits the kind. The v2 consumer bootstraps from the combined snapshot and uses an overlay-identity cursor, then revokes missing decision and trust overlays before checkpointing.
Open investigations at the release gate (SPRINT_20261007_013 CCP-028). A row whose effective status is under_investigation stores no affected range. On its own that makes the findings lookup list the CVE with match state unknown, which the promotion evaluator does not count as a breach (the range-less rule of 2026-06-23). Two kinds of row are decisions held open rather than range-less advisories, and the lookup judges them by what holds them open:
- A row under a live case hold: a
case-untrustoverlay on the issue that is not revoked and is inside its validity window, which the lookup reads frompolicy.vuln_tenant_overlayin the same statement as the row. Every deployed version of the held product is recorded as affected until the hold is withdrawn, so a livenot_affecteddecision cannot clear the case at the gate. The row’soverlay_appliedflag does not identify a hold: an environment issuer preference for an issuer whose own statement isunder_investigationalso sets it, and that row keeps the unknown rule below. - A row that awaits issuer confirmation is judged against the concrete
affectedand rangedfixedclaims in itsconfirmation_inputs, serialized by the sharedConsensusApplicabilityas if they had won. A deployed version inside such a claim is affected until an operator confirms an issuer; a version outside every claim stays a listed unknown.
An issuer’s own under_investigation and range-less statements keep the unknown rule, including when an environment issuer preference selects that issuer. Before this change both kinds of row let a known-vulnerable release through.
Unscanned candidates (CCP-028, acceptance manifest source prerequisite S11). The findings lookup reads Scanner’s digest detail for every release component image. An image without a successful scan (Scanner answers 404, or it has only failed or unfinished attempts) has no measured package set, so the lookup reports the findings source unavailable instead of matching the projection against the remaining components. The evaluator then denies under the default posture, or allows with a “findings source UNAVAILABLE” note under the warn posture, which ReleaseOrchestrator records as findings_projection_unavailable (unknown). For a promotion subject, “No findings resolved for subject” therefore means that every component image of the release was scanned and no projection row matched. It does not assert feed coverage: an image whose distro release no ingested feed covers also matches nothing, and no “release not covered” outcome exists yet (acceptance manifest source prerequisite S5, owned by Vulnerabilities and Scanner).
Findings basis (CCP-096, acceptance manifest source prerequisite S11). Every evaluate response states what its findings set rests on as evidence.findingsBasis: resolved (the findings source answered for the release), absent (the revision has CVE rules and no findings source exists for the subject), unavailable (a source exists and could not answer; the posture above decides) or not_consulted (no recognised CVE deny-list or reachability rule). A subject without a release: token has no findings source: the lookup reads no owner API and no projection, and the evaluator answers allow with findings basis absent and the note “findings basis absent: no findings source exists for the subject”, where it formerly gave the clean note “no findings resolved for subject”. ReleaseOrchestrator’s decision-basis classifier reads the structured value, not the note, and records findings_projection_absent, so no approval rests on such a verdict. A subject that names a release keeps the paths above unchanged: a clean scan is resolved and passes, and an unscanned image or an unready tenant is unavailable and denied fail-closed under the default posture. ReleaseOrchestrator’s promotion subjects always carry release:; the absent basis applies to other callers of the evaluate endpoint. The release-set verdict attestation carries the evidence record as its findings member, so new attestations include findingsBasis; an absent basis has no release and is never attested (evaluation_inputs_incomplete). Re-verify: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Class "*FindingsBasisAbsentTests" -BuildProjectReferences.
Source-verified against 0327acb2b3 (CCP-028 branch, 2026-10-10). Re-verify: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Class "*ReleaseGateVexAdvisoryAcceptanceTests" -BuildProjectReferences (the class drives the projector, the composed read, the findings lookup and the promotion evaluator with a compiled pack, for Debian and Alpine components, a patched scan, an unscanned image, a never-fed projection, a tenant activated after the derivation, trusted, untrusted, conflicting, changed, expired and held VEX, and an environment issuer preference for an issuer’s own open question).
Changed risk after deployment (CCP-051, acceptance case CA-11). A new or revised advisory or VEX statement reaches a deployed release through the same chain as a promotion: the hub publishes the change on its consensus stream, the gate projection admits each event once (inbox and checkpoint in one transaction) and re-reads the issue’s current statements, and the findings lookup and promotion evaluator answer the release subject from the projection. Policy does not track deployments and notifies no one on this path: Release Orchestrator’s estate re-touch job asks the evaluate endpoint again for every deployed release and owns the comparison with the authorizing decision, the notification and the estate reads (Release Orchestrator architecture). A changed findings set gives the subject a new evaluation correlation; a repeated notification for an issue whose statements did not change gives the same verdict and correlation. Source-verified against 4410b1d22f (2026-10-11). Re-verify: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Class "*ReleaseGateVexAdvisoryAcceptanceTests" -BuildProjectReferences (case ANewAdvisoryAfterDeployment_MakesTheSameReleaseSubjectAffected_AndARepeatedNotificationConverges) and the Release Orchestrator class DeployedEstateChangedRiskAcceptanceTests.
No other Policy component re-evaluates on a signal or policy change: the determinization signal-update handler and the incremental policy orchestrator, which no producer fed and the host never registered, were withdrawn on 2026-10-11 together with the unregistered exception recheck gate and exception-adapter integration (SPRINT_20261007_028 CCP-073; frozen source in src/__Obsoleted/Policy/StellaOps.Policy.Engine/CCP-073/). Since then no component acts on the determinization reanalysis trigger settings (DeterminizationOptions.Triggers) (Determinization API, section 12). Source-verified against 02993ecedd (2026-10-12). Re-verify: pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csproj -Class "*ReEvaluationAndRecheckGateWithdrawalTests" -BuildProjectReferences (the withdrawn types are absent from the Policy Engine assembly, the three registration routines are gone, and AddPolicyEngine, which runs AddDeterminizationEngine, registers none of the withdrawn services).
The messaging backend holds bounded evaluation and reachability cache entries only; packs, decisions and signing projections stay in PostgreSQL (src/Policy/StellaOps.Policy.Engine/README.md).
7. Configuration sections
Options sections bound by the Engine and its libraries include PolicyEngine, PolicyEngine:Tenancy, PolicyEngine:EvidenceWeightedScore, PolicyEngine:VulnGateProjection, PolicyEngine:VulnGateProjection:FindingsOverlays (Enabled, off in code and on in the shipped compose and bundle through POLICY_VEX_DECISION_OVERLAYS_ENABLED; BaseAddress, required when enabled; Timeout; BootstrapWarningAfter, default 15 minutes, the bound of the overlay bootstrap Doctor check; SystemDefaultTenantPollInterval, default 1 minute, how often the feed of a tenant on the system default overlay is read; the host refuses to start with it enabled and PolicyEngine:Authority:Enabled off), PolicyEngine:VulnGateProjection:SystemDefaultOverlay (Statements, empty by default; validated at start, section 6), PolicyEngine:FindingsLookup, PolicyGates, Policy:UnknownsGate, Policy:Exceptions:*, Policy:ExceptionApproval, Policy:GateBypassAudit, Policy:Retention, Determinization, ToolLattice, Opa, VexSigning, PolicyDecisionAttestation, RateLimiting, SmartDiff:Gates. Each is a SectionName constant in src/Policy/StellaOps.Policy.Engine or src/Policy/__Libraries.
SmartDiff:Gates configures the drift gate behind POST /api/v1/policy/gate/evaluate. Its AffectedReachable check (BlockOnAffectedReachable, default true) blocks a promotion whose delta makes a CVE newly reachable while the target knowledge snapshot states that CVE affected or under_investigation; a not_affected or fixed status, or no stated status, leaves a warning. The delta computer writes the snapshot’s status into each newly reachable driver as vex_status (SPRINT_20260930_002 PCD-195; before that nothing wrote it and the check could only warn).
8. Doctor
| Check | Source |
|---|---|
doctor.policy-engine.retention.job-budgets | src/Policy/StellaOps.Policy.Engine/Retention/PolicyRetentionDoctorCheck.cs, mapped by MapPolicyRetentionDoctor() |
doctor.policy-engine.capacity.budget | src/Policy/StellaOps.Policy.Engine/Retention/PolicyCapacityBudgetCheck.cs, registered by AddPolicyCapacityProbe() with the leased probe that writes policy.capacity_probe_receipt (migration 023) |
doctor.policy-engine.vex-overlays.bootstrap | src/Policy/StellaOps.Policy.Engine/Services/VexDecisionOverlays/VexOverlayBootstrapDoctorCheck.cs, registered with the decision-overlay feed by AddVexDecisionOverlayBootstrap() |
doctor.policy-engine.consensus.retention | Services/VulnGate/ConsensusProjectionDoctorCheck.cs, registered by AddConsensusProjectionDoctor() when the gate projection is enabled; compares the local checkpoint with owner-API epoch, head and retention metadata |
doctor.policy-engine.gate-inventory.quarantine | Services/VulnGate/GateInventoryQuarantineDoctorCheck.cs, registered by AddGateInventoryQuarantineDoctor() from AddConsensusProjectionDoctor(), so when the gate projection is enabled; unhealthy (warning) while an SbomService version or Scanner inventory is quarantined because the admission rule refused it. Evidence counts quarantined sources by owner and reason and names up to 25 by owner, source key, revision and reason, without tenant identifiers (quarantined owner inventory) |
The capacity check fails when the newest receipt is absent, older than 36 hours or at a stop line of the data-identity register’s budgets, and warns at an alarm line. The receipt carries the exact count of tenant override rows in policy.vuln_gate_current (SPRINT_20261007_062 DIP-14, Retention/PolicyDuplicationLines.cs): per tenant of the gate projector’s tenant catalog, under that tenant’s row-level-security context, over the primary key, capped at the stop line (budget 1,000,000; alarm 800,000, stop 950,000); like every duplication line it never pauses the gate (rules).
Capacity pause (SPRINT_20261007_062 DIP-13, ADR-039 P7). VulnGateProjectionHost consults the Policy capacity writer gate, which reads the same receipt. The gate is closed while the receipt shows stellaops_policy at its database stop line, while the receipt is older than Capacity:WriterGate:StaleAfter (48 hours by default), and while no receipt exists once Capacity:WriterGate:UnmeasuredGrace (20 minutes by default) has passed since the host started. While it is closed:
- Writers that admit growth wait: the
vuln.consensusevent drain and new relevant-inventory admission/backfill. A long drain re-reads the gate on its lease heartbeat, so a pause stops it at the next batch rather than at the end of the backlog. - The gate consumer checkpoint holds. Gates keep serving the last derived rows, and the readiness predicates still require complete coverage for the actual inputs, recovery/default readiness and the tenant’s overlay checkpoint when enabled. Gate freshness degrades: a hub consensus change made during the pause, such as a newly affected issue, does not reach the gates until the drain resumes. On a first start with no checkpoint, gates stay unavailable until certified backfill completes.
- Exact retirements, complete owner reconciliation and bounded pruning continue. New inventory is deferred durably without new source/selector/membership rows; reopening requires a fresh complete snapshot. A supersession retains still-needed membership and removes obsolete membership.
- The lease, the exploit-evidence mirror (a bounded replace of about 1,700 rows), the tenant VEX decision overlays, installation default apply and the environment-preference re-derive keep running. Their volume follows operator records/configuration, not the corpus, and stopping them would keep decisions an operator has already changed.
The drain resumes on the first poll after a receipt that is fresh and below the stop line. The gate logs one line when it closes or opens, not one per poll.
The overlay bootstrap check warns when a tenant the consumer reads (a pack, an overlay row, or listed as active by the tenants replica) that the replica does not exclude has had no policy.vuln_gate.vex_overlays_v3 checkpoint for longer than PolicyEngine:VulnGateProjection:FindingsOverlays:BootstrapWarningAfter after host start; such a tenant’s gates read unavailable. With an unanswering replica every tenant without a checkpoint counts. Tenants the replica does not list as active are counted as skipped, which is informational. The evidence carries counts, the gate checkpoint state and the replica state, never tenant identifiers; the policy-engine log names each skipped tenant and each feed failure. The message names the likely cause: inventory backfill has not checkpointed, the replica cannot answer, or a feed failure.
9. Retired surfaces
The unregistered trust-lattice gate classes, six excluded gate sources,
TrustLatticeEngineand the unused VEX decision emitter were frozen by PCD-382 (2026-10-05). The withdrawal boundary distinguishes the retained interop assessment algorithms from promotion enforcement and documents unsupported-type rejection. The removed classes’Policy:Gates:*option sections are no longer configuration inputs.StellaOps.Policy.Gatewayas a separate container: merged intopolicy-engine(see §1).policy.sealed_mode_states: retired forward by migration 010; Platform owns installation posture.Verdict Postgres persistence: retired 2026-09-23 (Q-43); never owned by Policy.
Remediation fix-template marketplace (
src/__Obsoleted/Remediation/): archived dossier atdocs-archive/modules/remediation/architecture.md; not a Policy capability.VEX trust, VEX proof and VEX status-promotion gates (
VexTrustGatewithPolicy:Gates:VexTrustand the inline VexTrust step ofPolicyGateEvaluator,VexProofGate,VexStatusPromotionGate): withdrawn 2026-10-04 (SPRINT_20260930_002 PCD-195) tosrc/__Obsoleted/Policy/**/PCD-195/. None was registered or fed, so none decided anything. VEX reaches promotion decisions through the gate projection above and the drift gate’s AffectedReachable check.The consensus trust setting
RestrictedStatuses(PolicyEngine:VulnGateProjection:Trust): withdrawn in the same change; the shared evaluator never read it.StellaOps.Policy.Predicatesand its test project: empty shells withdrawn on 2026-10-05 (SPRINT_20261003_003 PCD-342) tosrc/__Obsoleted/Policy/; core policy gates remain in__Libraries/StellaOps.Policy. They are excluded from the solution and test inventories. Source verification:3a83be7685; re-verify withgit grep -n "StellaOps.Policy.Predicates" -- src/StellaOps.sln src/Policy(zero live references).
10. Related documents
- README and the subordinate document index there.
../../architecture/database-ownership-matrix.md.
Bounded gate projection batches
Source verified against commit de98170965 (2026-10-10); the per-table statements against 9f7249d5d1 (2026-10-11). Re-verify from the repository root with:
pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Policy/__Tests/StellaOps.Policy.Persistence.Tests/StellaOps.Policy.Persistence.Tests.csproj -Class "*VulnGateCorpusSeedTests","*GateProjectionContractTests","*HubLookupConsensusInputSourceTests","*VulnGateProjectionHostTests" -BuildProjectReferences
The incremental projector hydrates distinct issue keys from each event page through the Vulnerabilities owner’s POST /api/vulnerabilities/v1/issues/facts:batch operation. The default event page holds at most 200 envelopes; larger configured windows are capped. A missing, malformed or failed owner reply refuses the page; an explicit empty fact list remains a valid answer. Oversized replies cause bounded key groups to split; an oversized single issue is refused, never truncated or retried without a bound. HTTP reads complete before the destination transaction opens.
One fenced destination transaction admits the page, derives each admitted issue once using the last notification’s linkset stamp, and advances the contiguous checkpoint. An epoch mismatch or failure rolls back the page. Unknown event types retain their skip-and-checkpoint behavior. Decision hashes, platform/FIPS scope selection and sparse overrides use the same consensus evaluator as before.
Relevant selector pages contain at most 500 candidate keys and retain their durable page/replay continuation. Overlay reads now cover the whole chunk once per tenant; global, tenant and environment writes/deletes are set based across its keys. Each tenant’s statements still run under its own row-level-security context. Removing stale scopes and overrides is part of the same transaction as the upserts. Unchanged decisions still produce no replacement row versions.
Each gate table has its own static delete and upsert in VulnGateProjector.Batching.cs, and each statement names its table literally; no statement interpolates a relation name (SPRINT_20261007_064 PGF-7):
| Table | Stale-row delete | Upsert |
|---|---|---|
policy.vuln_gate_global | GlobalBatchDeleteSql, DELETE FROM policy.vuln_gate_global d USING keys k: matched on issue and scope | GlobalBatchUpsertSql, INSERT INTO policy.vuln_gate_global ( |
policy.vuln_gate_current | TenantBatchDeleteSql, DELETE FROM policy.vuln_gate_current d USING keys k: also matched on tenant_id = @tenant | TenantBatchUpsertSql, INSERT INTO policy.vuln_gate_current ( |
policy.vuln_gate_environment_current | EnvironmentBatchDeleteSql, DELETE FROM policy.vuln_gate_environment_current d USING keys k: also matched on the tenant and the environment | EnvironmentBatchUpsertSql, INSERT INTO policy.vuln_gate_environment_current ( |
For the issue keys of its batch, a delete removes every row of its table that the batch’s upsert no longer writes; an issue left without statements loses all its global rows. Rows of issues outside the batch, another tenant’s rows and another environment’s rows are not touched. The retention inventory (docs/architecture/retention/p13-source-inventory.json) cites the three upserts as the tables’ rebuild paths and the environment delete as that table’s disposal boundary.
Roll out the Hub batch and indexed selector operations before the Policy caller. An older Hub’s refusal stops projection; incomplete deferred indexes withhold coverage. Interruption resumes the committed inventory/page/replay cursor. PGF-4 migration 027 supplies the new lifecycle metadata; gate evaluation still performs no remote vulnerability lookup.
The owner contracts, storage/disposal identities, configuration and exact 5M-row/4GB live acceptance requirements are described in Relevant inventory and gate coverage.
Consensus retention recovery
PGF-6 source verified against commit 852af2c0b5b466a38794739b24b197d4437563fb (2026-10-13). Re-verify with HttpHubEventFeedTests, VulnGateCorpusSeedTests, VulnGateProjectionHostTests and ConsensusProjectionDoctorCheckTests in the adjacent Policy test projects.
The hub’s retentionHorizonSeq is the oldest servable event. A checkpoint at horizon - 1 is contiguous; a checkpoint below that boundary, or explicit retention.belowWindow, requires a corpus rebuild. The drain checks this before applying any retained suffix, including empty pages. For a known epoch with an outstanding head, an empty page and no horizon can mean that every retained envelope was pruned. The drain verifies that condition with one additional owner read, then fences recovery. The owner reads metadata and envelopes in separate statements; the consumer does not treat a response as an atomic snapshot. Malformed metadata refuses the page and retries through the host’s existing backoff. Never-produced state requires explicit null epoch/head fields. Missing required fields or a non-object response is malformed, so an incomplete reply cannot certify healthy zero lag. The stream writer allocates dense positions per stream. The drain also checks returned positions for missing sequences, so a populated retained suffix cannot bypass recovery when pruning clears the horizon between the owner’s separate reads. Selector replay applies the same dense-position and actual epoch/head checks after all indexed pages are committed.
One fenced Policy transaction writes policy.vuln_gate#corpus-recovery-v1 in the existing consumer-checkpoint store and removes the gate, platform, trust and system-default completion receipts. The ordinary checkpoint is absent during recovery, and every gate reader also excludes the recovery fence explicitly. A producer epoch change takes the same rebuild path. The normal inbox epoch reset writes a sequence-zero checkpoint and therefore cannot certify this recovery.
The recovery lifecycle deletes only derived global, tenant and environment gate rows in fenced batches of at most 5,000 rows, advancing through indexed key ranges and renewing the lease between batches. Tenant deletes retain tenant predicates and row-level-security context. The tenant catalog includes all stored overlay records, including revoked records, so former override owners are included. Overlay inputs, preferences, packs and their feed checkpoints survive. A restart resumes bounded cleanup and the certified relevant backfill, removing partial generations and facts for issues or scopes that disappeared.
The lifecycle captures actual owner and Hub fences and commits completion only after all selector pages, required replay and relevant overlay-only issues are durable. If retention overtakes a fence, completion is withheld and recovery restarts. Complete empty inventory/selector results are explicit owner answers; unavailable or partial data is never an empty success. Recovery retries on the idle path through the live indexed Hub owner source. An unavailable source preserves the fence. Mounted corpus sections carry no consensus-stream coverage certificate: neither their mount time nor their generation proves that changes before the captured head are present. The host therefore refuses mounted recovery, keeps the durable fence and absent completion checkpoint across idle polls and restarts. Gate reads remain unavailable and Doctor reports active recovery; unchanged head/epoch/horizon during a walk cannot certify an old mount. This refusal also applies when old bytes are mounted later with a larger generation. Switching a previously certified projection to an uncertified source also revokes completion before owner I/O; an unavailable head supplies no invented watermark. The supported source is selected by PolicyEngine:VulnGateProjection:InputSource=hub-lookup, using the configured Hub client, indexed selector pages and existing fact/event operations.
The local Doctor check reports epoch, head, horizon, checkpoint, lag, remaining retained margin and recovery state. It warns when a lagging consumer has at most PolicyEngine:VulnGateProjection:RetentionDoctor:WarningRemainingEvents remaining before the horizon (default 100,000; startup validation permits 1 through 10,000,000). Margin counts from the next unread sequence, so a contiguous horizon - 1 checkpoint has margin zero. A gap, epoch mismatch, or active recovery fails the check. Missing checkpoint or insufficient retention metadata for an outstanding backlog is unhealthy and distinct from a never-produced stream. A same-epoch checkpoint equal to the observed head is caught up even when all retained events were pruned and the horizon is null. An unreachable or timed-out owner fails with the exception type alone; it never reports healthy zero lag. The check uses the existing local Doctor endpoint and grants; global Doctor registration remains a separate owner task.
There is no remote consumer registration, retention-floor hold, new schema or direct sibling database access. Deployment remains held for the accepted PGF-4/DIP-27/PGF-6 source union and roll-forward plan. Older Policy code does not recognize the recovery fence: it must not be started over an incomplete recovery or treated as an ordinary rollback target.
