= 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<ApprovalState>`.

=== 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.
