= core-approvals == purpose Evaluates whether a proposed operation should proceed by checking active grants and session approval mode, then returning an approval decision (auto-approved or pending human review). Tracks requests, decisions, and grants as event-sourced state. == responsibilities * Approving or rejecting operations based on tier, grants, and approval mode * Tracking pending approval requests and resolved decisions * Recording active grants (time-bounded permissions scoped to session/stage/project) * Rebuilding approval state from the event log * Providing a no-op engine for deterministic replay == non-responsibilities * Does not enforce policy — policy is absolute upstream * Does not emit events — all events are consumed from the event store * Does not validate whether the operation itself is valid (delegated to validation) * Does not determine execution tiers — tiers are declared by tools == key types === ApprovalReducer * **kind**: interface * **purpose**: Transforms approval state given a stored event. === DefaultApprovalReducer * **kind**: class * **purpose**: Handles `ApprovalRequestedEvent`, `ApprovalDecisionResolvedEvent`, `ApprovalGrantCreatedEvent`, `ApprovalGrantExpiredEvent`. === ApprovalProjector * **kind**: class * **purpose**: Wraps `ApprovalReducer` as a `Projection`. === DefaultApprovalRepository * **kind**: class * **purpose**: Rebuilds `ApprovalState` for a session via `EventReplayer`. === ApprovalState * **kind**: data class * **purpose**: In-memory projection of all approval-state for a session. * **fields**: `requests`, `decisions`, `grants` === DomainApprovalRequest * **kind**: data class * **purpose**: A request for approval of a tiered operation. * **fields**: `id` (ApprovalRequestId), `tier`, `validationReportId`, `riskSummaryId`, `timestamp`, `causationId`, `correlationId`, `userSteering`, `toolName`, `preview` === ApprovalDecision * **kind**: data class * **purpose**: The outcome of an approval evaluation. * **fields**: `outcome` (ApprovalOutcome), `state` (ApprovalStatus), `tier`, `contextSnapshot`, `reason`, `userSteering` === ApprovalGrant * **kind**: data class * **purpose**: A time-bounded permission that bypasses approval for matching operations. * **fields**: `id` (GrantId), `scope` (GrantScope), `permittedTiers`, `reason`, `timestamp`, `expiresAt` === ApprovalContext * **kind**: data class * **purpose**: Snapshot of identity, mode, and causality chain at decision time. * **fields**: `identity` (ApprovalScopeIdentity), `mode` (ApprovalMode), `causationId`, `correlationId` === ApprovalScopeIdentity * **kind**: data class * **purpose**: Identifies the scope (session, stage, project) a decision applies to. === ApprovalEngine * **kind**: interface * **purpose**: Evaluates a request against grants and approval mode to produce a decision. === DefaultApprovalEngine * **kind**: class * **purpose**: Implements the tier-based approval logic. Checks grants first, then falls through to mode-based thresholds. Returns `PENDING` for requests above the approval threshold. === NoOpApprovalEngine * **kind**: class * **purpose**: Always returns `AUTO_APPROVED` with no logic. Used during replay to avoid live approval evaluation. == event flow *inbound:* * `ApprovalRequestedEvent` → creates a `DomainApprovalRequest` in state * `ApprovalDecisionResolvedEvent` → creates an `ApprovalDecision` in state * `ApprovalGrantCreatedEvent` → creates an `ApprovalGrant` in state * `ApprovalGrantExpiredEvent` → removes the grant from state *outbound:* * None. This module is purely a projection — events are emitted by the orchestrator and consumed here. == integration points * `:core:events` — `StoredEvent`, event payload types, shared identity types (`SessionId`, `ApprovalRequestId`, etc.) * `:core:sessions` — `Projection`, `EventReplayer`, `ApprovalMode` == invariants * `ApprovalDecision.isFinal` is true only when `state != PENDING` * `ApprovalDecision.isApproved` is true only when outcome is `APPROVED` or `AUTO_APPROVED` * A grant with `expiresAt <= now` is never matched by `DefaultApprovalEngine` * `NoOpApprovalEngine` is used exclusively on the replay path — never during live execution * Request, decision, and grant IDs are globally unique; maps in `ApprovalState` are indexed by these IDs == PlantUML diagram [plantuml, core-approvals, "png"] ---- include::../../diagrams/core-approvals.puml[] ---- == known issues * `ApprovalDecision.id` is nullable in the data class but `DefaultApprovalReducer` always assigns a non-null ID; `NoOpApprovalEngine` returns `null` for the ID, which could make downstream consumers handle both cases unnecessarily. * `ApprovalContext` construction in `DefaultApprovalReducer` hardcodes `ApprovalMode.PROMPT` and null stage/project IDs because the event does not carry this information. The full context is only available at evaluation time. == open questions * Should `ApprovalContext` be embedded in the event payload rather than reconstructed during reduction? * The `RiskSummaryId` on `DomainApprovalRequest` references data from the validation module — the two modules are separate and share no common risk model type.