Exception Approval Workflow

Audience: security and release-governance owners who request, approve, or audit policy exceptions, and developers working on the Policy exception subsystem (src/Policy).

Reconciliation status (verified against src/Policy): This flow was rewritten to match the implemented exception subsystem. The implementation provides two related surfaces:

  1. Exception Objects — /api/policy/exceptions (lifecycle CRUD + governed state machine). Code: StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionEndpoints.cs and the StellaOps.Policy.Exceptions library.
  2. Exception Approval Requests — /api/v1/policy/exception (gate-level multi-approver workflow). Code: StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs, StellaOps.Policy.Engine/Services/ExceptionApprovalRulesService.cs, and the ExceptionApprovalRequestEntity / ExceptionApprovalRuleEntity persistence models.

Both surfaces are mapped by the Policy Engine (Program.cs: MapExceptionEndpoints()

  • MapExceptionApprovalEndpoints()). The separate Policy Gateway host was withdrawn; the Engine serves its routes. Org-role concepts such as “Team Lead”, “Security Team”, and “CISO” are not system actors — they exist only as configurable approver role strings in gate-level rules (code-owner, delivery-manager, product-manager, ciso).

Automatic expiry and expiry-warning emission are implemented as in-process hosted BackgroundService workers (ExceptionExpiryWorker, ExceptionLifecycleWorker); expiry warnings are emitted to the unified audit/Timeline emitter (IExceptionNotificationService, disabled by default), not the standalone src/Notify module. See Expiration & Lifecycle. (A prior reconciliation pass incorrectly stated these were absent.)

Overview

The Exception Approval Workflow describes how StellaOps handles policy exception requests, from submission through gate-level approval, time-limited validity, and expiry. Exceptions are first-class auditable entities (not suppression files or UI toggles) with full lifecycle tracking, immutable event history, and deterministic evaluation.

Business Value: Maintain security governance while allowing controlled, time-bounded exceptions for legitimate business needs, with a complete audit trail and enforced separation-of-duty.

Actors

ActorTypeRole in the implemented flow
RequesterHumanCreates an exception object (policy:author) or an approval request (exceptions:request).
ApproverHumanApproves/rejects via the gate-level workflow (exceptions:approve) or approves an exception object (policy:operate). Must hold the required role(s) for the gate level.
Policy EngineServiceHosts the exception and approval-request APIs and evaluates approval rules. Where an exception object takes effect is described under Exception Enforcement.
Expiry / lifecycle workersService (background)Hosted BackgroundService workers in the Policy Engine that record expiry and emit expiry warnings. See Expiration & Lifecycle.

No external Scheduler/JobEngine dependency: expiry and lifecycle transitions run as in-process hosted workers in the Policy services (not as JobEngine jobs). Expiry warnings are emitted to the unified audit/Timeline emitter, not to the standalone src/Notify channel-dispatch module (no email/Slack templates exist). See Expiration & Lifecycle and Not Implemented / Roadmap.

Prerequisites

Authorization

Scopes are sourced from the canonical catalog (StellaOps.Auth.Abstractions/StellaOpsScopes.cs). Note the two surfaces use different scopes.

Exception Objects (/api/policy/exceptions)

OperationRequired scope
List / counts / get / history / expiringpolicy:read
Create (POST) / update (PUT)policy:author
Approve / activate / extend / revoke (DELETE)policy:operate

Exception Approval Requests (/api/v1/policy/exception)

OperationRequired scope
Create request / cancelexceptions:request
Get / list / audit / rulesexceptions:read
List pending (approver inbox) / approve / rejectexceptions:approve

Exception Object Model

An exception object (ExceptionObject) is a versioned, append-only-audited entity.

Status (lifecycle)

Proposed → Approved → Active → Expired
                       Active → Revoked
       Proposed/Approved/Active → Revoked   (early termination / rejection)
StatusMeaning
proposedRequested, awaiting approval.
approvedApproved, awaiting activation.
activeCurrently affecting policy evaluation.
expiredexpires_at reached.
revokedExplicitly revoked (or rejected before approval).

Type

TypeMeaning
vulnerabilityException for a specific CVE/CWE.
policyBypass of a specific policy rule.
unknownAllow release despite unknown findings.
componentException for a specific component/package.

Reason code (ExceptionReason)

false_positive, accepted_risk, compensating_control, test_only, vendor_not_affected, scheduled_fix, deprecation_in_progress, runtime_mitigation, network_isolation, other.

Scope (ExceptionScope)

At least one constraint must be set; multiple constraints combine with AND.

FieldNotes
artifact_digestsha256:..., exact match (most specific).
purl_patternSupports * wildcards (e.g. pkg:npm/lodash@*, pkg:maven/org.apache.logging.log4j/*).
vulnerability_idExact CVE/CWE ID.
policy_rule_idPolicy rule being bypassed.
environmentsAllowed environments; empty array = all environments.
tenant_idOwning tenant, stored as text. The create route stores the tenant of the authenticated request and the approval workflow stores the approval request’s tenant; a create without a tenant is refused. The list, counts, by-id, history and expiring reads and the update, approve, activate, extend and revoke routes see only the caller’s tenant: another tenant’s exception id answers 404, as an unknown id does. Objects created before the tenant was stored are kept under tenant default and are not reassigned.

Gate-Level Approval Rules

The approval-request workflow (/api/v1/policy/exception) routes each request to a gate level (G0–G4). If no tenant-specific ExceptionApprovalRuleEntity is configured (and enabled), these built-in defaults apply (ApprovalRequirements.GetDefault):

GateMinApproversRequired rolesMaxTtlDaysSelf-approvalEvidence req.Comp. controls req.MinRationaleLength
G0 (informational)0 (auto-approve)—90yesnono0
G1 (low)1—60yesnono20
G2 (medium)1code-owner30noyesno50
G3 (high)2delivery-manager, product-manager14noyesyes100
G4 (critical)3ciso, delivery-manager, product-manager7noyesyes200

Notes:

Flow Diagram (gate-level approval request)

┌──────────────────────────────────────────────────────────────────────┐
│         Exception Approval Request Workflow (gate-level)               │
└──────────────────────────────────────────────────────────────────────┘

┌───────────┐                 ┌──────────────────────┐     ┌──────────────┐
│ Requester │                 │ Policy Engine         │     │  Approver(s) │
└─────┬─────┘                 └──────────┬───────────┘     └──────┬───────┘
      │ POST /request                    │                        │
      │  (justification, gate level,     │                        │
      │   scope, ttl, evidence)          │                        │
      │─────────────────────────────────>│                        │
      │                                  │ Resolve requirements   │
      │                                  │ (tenant rule || default)│
      │                                  │ Validate TTL/rationale/ │
      │                                  │ evidence/controls       │
      │                                  │───┐                     │
      │                                  │<──┘                     │
      │                                  │ If G0 / MinApprovers==0 │
      │                                  │ → auto-approve          │
      │                                  │ Persist + audit         │
      │ 201 Created (EAR-…, status,      │                         │
      │   warnings)                      │                         │
      │<─────────────────────────────────│                        │
      │                                  │                         │
      │                                  │  GET /pending           │
      │                                  │<────────────────────────│
      │                                  │  (approver inbox)       │
      │                                  │  POST /{id}/approve     │
      │                                  │<────────────────────────│
      │                                  │ Validate action:        │
      │                                  │  - not already approved │
      │                                  │  - self-approval rule   │
      │                                  │  - in required list     │
      │                                  │  - role coverage        │
      │                                  │ Record approval; when   │
      │                                  │ MinApprovers met →      │
      │                                  │ status = Approved       │
      │                                  │                         │
      │                                  │  POST /{id}/reject       │
      │                                  │  (mandatory reason)      │
      │                                  │<────────────────────────│
      │                                  │                         │

Step-by-Step (gate-level approval request)

1. Create approval request

POST /api/v1/policy/exception/request (scope exceptions:request).

Request body (CreateApprovalRequestDto):

{
  "exceptionId": "EXC-… (optional link to an exception object)",
  "justification": "Upgrading lodash breaks the auth flow; need time to refactor.",
  "rationale": "Vulnerability requires prototype pollution not reachable in our usage.",
  "gateLevel": "G3",
  "reasonCode": "scheduled_fix",
  "evidenceRefs": ["sha256:…"],
  "compensatingControls": ["WAF rule blocking exploitation vectors"],
  "ticketRef": "JIRA-1234",
  "vulnerabilityId": "CVE-2024-1234",
  "purlPattern": "pkg:npm/lodash@*",
  "artifactDigest": null,
  "imagePattern": "docker.io/myorg/auth-service:*",
  "environments": ["production"],
  "requiredApproverIds": ["alice", "bob"],
  "requestedTtlDays": 14,
  "metadata": { "team": "auth" }
}

Server behaviour:

Response: 201 Created with the full ApprovalRequestDto (includes status, gateLevel, requestExpiresAt, exceptionExpiresAt, and any validation warnings, e.g. “No scope constraints specified.”).

2. Approver inbox

GET /api/v1/policy/exception/pending (scope exceptions:approve) returns requests where the caller is a required approver and has not yet approved.

3. Approve

POST /api/v1/policy/exception/{requestId}/approve (scope exceptions:approve).

ValidateApprovalActionAsync enforces:

When MinApprovers is reached (and outstanding required roles cleared), the request transitions to Approved. Optional comment is captured.

4. Reject

POST /api/v1/policy/exception/{requestId}/reject (scope exceptions:approve). Requires a non-empty reason. Only Pending/Partial requests can be rejected; transitions to Rejected (terminal). The reason is surfaced to the requester.

5. Cancel

POST /api/v1/policy/exception/{requestId}/cancel (scope exceptions:request). Only the original requestor may cancel (returns 403 otherwise); returns 400 if already resolved; 204 No Content on success.

6. Audit & rules

Step-by-Step (exception object lifecycle)

The /api/policy/exceptions surface manages the exception object directly.

StepEndpointScopeNotes
CreatePOST /api/policy/exceptionspolicy:authorStatus proposed. Validates expiry is in the future and ≤ 1 year. ID = EXC-{guid} (20 chars).
UpdatePUT /api/policy/exceptions/{id}policy:authorMutates rationale/evidence/controls/ticket/metadata; bumps version. Cannot update expired/revoked.
ApprovePOST /api/policy/exceptions/{id}/approvepolicy:operateOnly proposed → approved, and only before expires_at. Self-approval rejected (approver ≠ requester). Appends approver.
ActivatePOST /api/policy/exceptions/{id}/activatepolicy:operateOnly approved → active, and only before expires_at.
ExtendPOST /api/policy/exceptions/{id}/extendpolicy:operateOnly active and only before expires_at; new expiry must be later than current. A lapsed exception needs a new request.
RevokeDELETE /api/policy/exceptions/{id}policy:operateAny non-terminal → revoked. Optional reason.
ListGET /api/policy/exceptionspolicy:readFilters: status, type, vulnerability ID, PURL pattern, environment, owner; paginated (limit 1–100, default 50).
CountsGET /api/policy/exceptions/countspolicy:readTotals by status + expiring-soon.
GetGET /api/policy/exceptions/{id}policy:readFull detail or 404.
HistoryGET /api/policy/exceptions/{id}/historypolicy:readOrdered ExceptionEvent audit trail.
ExpiringGET /api/policy/exceptions/expiring?days=7policy:readActive exceptions expiring within the horizon (default 7 days).

Example: create exception object

{
  "type": "vulnerability",
  "scope": {
    "vulnerabilityId": "CVE-2024-1234",
    "purlPattern": "pkg:npm/lodash@*",
    "environments": ["production"]
  },
  "ownerId": "auth-team",
  "expiresAt": "2025-01-28T14:30:00Z",
  "reasonCode": "scheduled_fix",
  "rationale": "Upgrade breaks the auth flow; WAF mitigation in place while we refactor.",
  "evidenceRefs": ["sha256:…"],
  "compensatingControls": ["WAF rule deployed"],
  "ticketRef": "JIRA-1234",
  "metadata": { "team": "auth" }
}

Exception Enforcement (policy evaluation)

An exception object is effective only while its status is active and the current time is before expires_at (ExceptionObject.IsEffectiveAt). Where an exception object takes effect today:

The library evaluator IExceptionEvaluator.EvaluateAsync (StellaOps.Policy.Exceptions) has no consumer in the product. Its only caller, the exception recheck build gate, and the exception adapter for runtime evaluation (AddPolicyExceptionIntegration) were never registered by the Policy Engine host and were withdrawn on 2026-10-11 (SPRINT_20261007_028 CCP-073). Given a FindingContext (artifactDigest, purl, vulnerabilityId, policyRuleId, environment, tenantId), the evaluator:

  1. Queries active exceptions for the scope (GetActiveByScopeAsync).
  2. Filters to those that truly match the context (exact match for digest / vuln ID / rule ID; wildcard PURL match; environment membership; tenant binding) and are IsEffectiveAt(now) (status Active and not past expires_at).
  3. Orders matches by a specificity score (artifact digest 100 > exact PURL 50 > vuln ID 40 > policy rule 30 > wildcard PURL 20 > environment 10). The most specific match is the primary.
  4. Returns HasException, all matching exceptions, the primary reason/rationale, and the union of evidence references.

Recheck Policy & Evidence Requirements (implemented, optional)

The StellaOps.Policy.Exceptions library defines additional governance primitives that an exception object may carry:

These are part of the model and library services only. The create and update requests carry no recheckPolicy and no route, worker or gate evaluates recheck conditions (the unregistered exception recheck build gate was withdrawn on 2026-10-11), so a triggered condition changes nothing today. See Recheck Policy Reference.

Expiration & Lifecycle

Background workers (verified)

Expiry is not only lazy: two in-process hosted BackgroundService workers run the status sweep and warnings. Both are registered with AddHostedService<…>():

WorkerService / registrationCadenceWhat it does
ExceptionExpiryWorkerPolicy Engine (Program.cs)hourly (Interval, default 1h; InitialDelay 30s), on the host TimeProviderLoads GetExpiredActiveAsync() (every tenant) and applies the audited transition: active → expired with a new version and an Expired history event (system actor system:expiry-worker, with ConcurrencyException retry-next-cycle); earlier events are kept. Then loads GetExpiringAsync(WarningHorizon) (default 7 days) and calls IExceptionNotificationService.NotifyExceptionExpiringSoonAsync once when the time until expiry crosses the 7-, 3- or 1-day threshold within the window since its previous successful read (CrossedWarningThreshold). Gated by ExceptionExpiryOptions.Enabled (default true; section Policy:Exceptions:Expiry).
ExceptionLifecycleWorker (+ ExceptionLifecycleService)Policy Engine (Program.cs)polling (PollIntervalSeconds, default 60s), on the host TimeProviderPer-configured-tenant expiry sweep: for each tenant in PolicyEngine:ResourceServer:RequiredTenants it loads GetExpiredActiveAsync(tenant) and applies the same audited transition as ExceptionExpiryWorker (new version, one Expired event by system:expiry-worker). With no tenant configured it does nothing; the compose files under devops/compose configure none for policy-engine. Options: PolicyEngineExceptionLifecycleOptions.

Earlier drafts claimed “no Scheduler job is wired” and that the “7-day / 1-day reminders” were not implemented. Both claims are incorrect against current source — see the worker table above. What does not exist is an external JobEngine/Scheduler job: this work runs as in-process hosted services in the Policy Engine.

Lifecycle notifications (verified)

IExceptionNotificationService (interface in StellaOps.Policy.Engine/Services/Gateway/ExceptionService.cs) has these hooks. Only the expiry worker’s warning is fired today: the other five are fired by ExceptionService, which no route or worker calls (see the note below).

HookFired fromEmitted action
NotifyExceptionCreatedAsyncExceptionService.CreateAsyncexception.created
NotifyExceptionApprovedAsyncExceptionService.ApproveAsyncexception.approved (incl. approverId)
NotifyExceptionActivatedAsyncExceptionService.ActivateAsyncexception.activated
NotifyExceptionExtendedAsyncExceptionService.ExtendAsyncexception.extended (incl. newExpiry)
NotifyExceptionRevokedAsyncExceptionService.RevokeAsyncexception.revoked (incl. reason; severity warning)
NotifyExceptionExpiringSoonAsyncExceptionExpiryWorkerexception.expiring_soon (incl. timeUntilExpirySeconds)

The registered implementation is AuditExceptionNotificationService, which emits an AuditEventPayload (module policy, resource type exception) to the unified audit/Timeline emitter (IAuditEventEmitter). It is disabled by default (ExceptionNotificationOptions.Enabled = false; section Policy:Exceptions:Notifications) — when disabled it logs and skips emission. There is no email/Slack/channel dispatch through the standalone src/Notify module; these notifications are audit/Timeline events only.

Note: ExceptionService (IExceptionService) is registered in the Policy Engine but has no caller. The minimal-API handlers documented under exception object lifecycle operate directly on IExceptionRepository and therefore do not emit these notifications; the audit history (/history) is recorded via the repository. ExceptionService applies the same rules as the routes: every call names the tenant, another tenant’s exception answers NotFound, and approve, activate and extend refuse an exception at or after its expires_at.

Data Contracts

Exception object (response — ExceptionResponse)

interface ExceptionResponse {
  exceptionId: string;          // EXC-…
  version: number;
  status: 'proposed' | 'approved' | 'active' | 'expired' | 'revoked';
  type: 'vulnerability' | 'policy' | 'unknown' | 'component';
  scope: {
    artifactDigest?: string;
    purlPattern?: string;
    vulnerabilityId?: string;
    policyRuleId?: string;
    environments: string[];
  };
  ownerId: string;
  requesterId: string;
  approverIds: string[];
  createdAt: string;
  updatedAt: string;
  approvedAt?: string;
  expiresAt: string;
  reasonCode: 'false_positive' | 'accepted_risk' | 'compensating_control'
            | 'test_only' | 'vendor_not_affected' | 'scheduled_fix'
            | 'deprecation_in_progress' | 'runtime_mitigation'
            | 'network_isolation' | 'other';
  rationale: string;
  evidenceRefs: string[];
  compensatingControls: string[];
  ticketRef?: string;
  metadata: Record<string, string>;
}

Approval request (response — ApprovalRequestDto)

interface ApprovalRequestDto {
  requestId: string;            // EAR-yyyyMMdd-XXXXXXXX
  id: string;                   // GUID
  tenantId: string;
  exceptionId?: string;
  requestorId: string;
  requiredApproverIds: string[];
  approvedByIds: string[];
  rejectedById?: string;
  status: 'Pending' | 'Partial' | 'Approved' | 'Rejected' | 'Expired' | 'Cancelled';
  gateLevel: 'G0' | 'G1' | 'G2' | 'G3' | 'G4';
  justification: string;
  rationale?: string;
  reasonCode: string;           // ExceptionReasonCode enum name
  evidenceRefs: string[];
  compensatingControls: string[];
  ticketRef?: string;
  vulnerabilityId?: string;
  purlPattern?: string;
  artifactDigest?: string;
  imagePattern?: string;
  environments: string[];
  requestedTtlDays: number;
  createdAt: string;
  requestExpiresAt: string;
  exceptionExpiresAt?: string;
  resolvedAt?: string;
  rejectionReason?: string;
  version: number;
  updatedAt: string;
  warnings: string[];
}

Approval rule (response — ApprovalRuleDto)

interface ApprovalRuleDto {
  id: string;
  name: string;
  description?: string;
  gateLevel: 'G0' | 'G1' | 'G2' | 'G3' | 'G4';
  minApprovers: number;
  requiredRoles: string[];
  maxTtlDays: number;
  allowSelfApproval: boolean;
  requireEvidence: boolean;
  requireCompensatingControls: boolean;
  minRationaleLength: number;
  enabled: boolean;
}

Error Handling

ConditionHTTPSource
Missing request body400both surfaces
Object expiry in the past or > 1 year400ExceptionEndpoints create
Update/revoke an already expired/revoked object400ExceptionEndpoints
Approve a non-proposed object, or one past expires_at400ExceptionEndpoints approve
Self-approval of object (approver == requester)400ExceptionEndpoints approve
Activate a non-approved object, or one past expires_at400ExceptionEndpoints activate
Extend a non-active object or one past expires_at, or with new expiry ≤ current400ExceptionEndpoints extend
Request TTL > gate MaxTtlDays400CreateApprovalRequestAsync / rules service
Validation failure (rationale/evidence/controls/justification)400ValidateRequestAsync
Approval not allowed (state, dup, self, not-in-list, roles)400ValidateApprovalActionAsync
Reject without reason400RejectRequestAsync
Cancel by non-owner403CancelRequestAsync
Request/exception not found404both surfaces
Missing tenant / actor401approval endpoints

Observability

The specific metric/log-event names below are illustrative and not verified against an emitted-telemetry catalog. The approval-request endpoints are decorated with .Audited("policy", "<action>", resourceType: "exception_approval_request") (create / approve / reject / cancel), so those actions emit standard audit events; structured information logs (e.g. “Exception request {RequestId} approved by {ApproverId}”) are emitted by the handlers.

Audit actions (verified)

EndpointAudit actionResource type
POST /requestcreateexception_approval_request
POST /{id}/approveapproveexception_approval_request
POST /{id}/rejectrejectexception_approval_request
POST /{id}/cancelcancelexception_approval_request

Exception-object events (Created, Updated, Approved, Activated, Extended, Revoked, Expired, EvidenceAttached, CompensatingControlAdded, Rejected) are persisted as ExceptionEvent audit entries and exposed via the /history endpoint.

Not Implemented / Roadmap

The following items appeared in earlier drafts of this flow but are not present in src/and should be treated as roadmap, not current behavior: