ApprovalPolicyService¶
Target
This page describes intended behaviour or structure. No implementation is claimed unless a section is explicitly marked Current implementation or Evidence.
| Bounded context | BC-20 Approval Policy |
| Platform / plane | Governance / Trust & Operations |
| Deployment granularity | module of host · host GovernanceHost |
| Status | target · ConnectSoft.Factory.Governance.Approvals |
| Built from | connectsoft-microservice · libraries: connectsoft-extensions-saas-abstractions, connectsoft-extensions-messaging-model, connectsoft-extensions-persistence-model-nhibernate, connectsoft-extensions-audit-net |
| Decisions | ADR-0014, ADR-0015 |
| Traceability | FR-06 FUX-05 NFR-04 XL-05 XL-02 |
Purpose and responsibilities¶
The ownership map splits approvals in two: instances and decisions are Run Control's (BC-03); the policy is here. ApprovalPolicyService holds the rules per scope and action class (D21 classes: shared-write, external, production, publication, spend) with the roles that may decide (Reviewer, Workspace admin; Platform admin as break-glass), the separation-of-duties constraints of ADR-0015 point 6 (never the requester, never the author), quorum, expiry and break-glass allowance. At request time it routes (eligible approvers, expiry); at decision time it validates (eligibility now, exclusions, timeliness, quorum). Project rules may only tighten tenant rules.
Context and boundaries¶
flowchart LR
S["ApprovalPolicyService"]
RunControlService["RunControlService"] -->|grpc| S
StudioBff["StudioBff"] -->|sync-http| S
S -->|event| PolicyEngineService["PolicyEngineService"]
S -->|grpc| ProjectService["ProjectService"]
S -->|sync-http| ext_identity_platform["ext-identity-platform"]
S -. "ApprovalRuleActivated" .-> E_ApprovalRuleActivated(("event"))
S -. "ApprovalRouted" .-> E_ApprovalRouted(("event"))
S -. "ApprovalDecisionValidated" .-> E_ApprovalDecisionValidated(("event"))
Domain model¶
ApprovalRule — A rule for one scope and action class — approver roles, separation-of-duties constraints, quorum, expiry, break-glass allowance and the policy revision it belongs to. (catalog entry)
| Field | Type | Required | Description |
|---|---|---|---|
ruleId |
string |
yes | |
tenantId |
string |
yes | |
scope |
string |
yes | platform |
actionClass |
string |
yes | shared-write |
actionPattern |
string |
no | Optional narrowing to an action family (tool id |
approverRoles |
list:string |
yes | |
excludeRequester |
bool |
yes | |
excludeAuthor |
bool |
yes | For publication actions the asset author may not approve. |
quorum |
int |
yes | |
expiryMinutes |
int |
yes | |
breakGlassAllowed |
bool |
yes | |
policyRevision |
string |
yes | |
active |
bool |
yes |
Invariants:
- excludeRequester is always true (ADR-0015 point 6); a rule that sets it false is refused at activation.
- A project rule may only tighten the tenant rule (more roles excluded, shorter expiry, higher quorum, break-glass disabled), never loosen it.
- Expiry never exceeds the platform maximum; an expired request can only fail the action, never be revived.
Events: ApprovalRuleActivated · Tables: ApprovalRules · Repository: ApprovalRuleRepository
RoutingEvaluation — The record of routing one approval request (eligible approvers, expiry) or validating one decision (eligibility, separation of duties, timeliness) under the rule in force. (catalog entry)
| Field | Type | Required | Description |
|---|---|---|---|
evaluationId |
guid |
yes | |
tenantId |
string |
yes | |
approvalId |
guid |
yes | Run Control approval request id. |
runId |
guid |
no | |
ruleId |
string |
yes | |
actionClass |
string |
yes | |
requesterId |
string |
yes | |
authorId |
string |
no | |
eligibleApprovers |
list:string |
no | Resolved subjects or groups at routing time. |
deciderId |
string |
no | |
outcome |
enum:RoutingOutcome |
yes | |
breakGlass |
bool |
yes | |
expiresAt |
datetime |
yes | |
evaluatedAt |
datetime |
yes |
Invariants:
- A decision by the requester or the author is Invalid regardless of role; a Platform admin break-glass decision is valid only with a reason and when the rule allows it, and is always audited.
- Eligibility is evaluated against memberships at decision time, not at routing time; a revoked role invalidates a pending decision.
Events: ApprovalRouted, ApprovalDecisionValidated · Tables: RoutingEvaluations · Repository: RoutingEvaluationRepository
erDiagram
ApprovalRule {
string ruleId PK
string tenantId
string scope
string actionClass
string actionPattern
list_string approverRoles
bool excludeRequester
bool excludeAuthor
int quorum
int expiryMinutes
bool breakGlassAllowed
string policyRevision
bool active
}
RoutingEvaluation {
guid evaluationId PK
string tenantId
guid approvalId
guid runId
string ruleId
string actionClass
string requesterId
string authorId
list_string eligibleApprovers
string deciderId
enum_RoutingOutcome outcome
bool breakGlass
datetime expiresAt
datetime evaluatedAt
}
Entities and value objects¶
None modelled
No entities or value objects are modelled for this item yet.
Data model and storage¶
| Store | Role | Engine / profile | Tables |
|---|---|---|---|
| Governance store | primary | azure-sql / nhibernate-sqlserver | ApprovalRules, RoutingEvaluations |
| Table | Owner | Columns | Indexes |
|---|---|---|---|
dbo.ApprovalRules |
ApprovalRule | 13 | 1 |
dbo.RoutingEvaluations |
RoutingEvaluation | 14 | 1 |
Full DDL: appendix.
APIs¶
| Method | Path | Operation | Scope | Side effects | Idempotency | Gateway | Summary |
|---|---|---|---|---|---|---|---|
GET |
/factory/approval-rules |
ListApprovalRules | factory.policies.read |
read | natural | GW-03 | Rules per scope and action class with roles |
POST |
/factory/projects/{projectId}/approval-rules/validate |
ValidateProjectApprovalRules | factory.policies.write |
sandbox | natural | GW-03 | Validate project rules against tenant rules (tighten only). |
GET |
/factory/approvals/{approvalId}/routing |
GetApprovalRouting | factory.approvals.read |
read | natural | GW-03 | Who may decide a request |
| gRPC service | Method | Streaming | Visibility | Side effects | Summary |
|---|---|---|---|---|---|
ApprovalPolicy |
Route |
none | internal | read | Resolve the rule |
ApprovalPolicy |
ValidateDecision |
none | internal | read | Validate that a decider is eligible now |
Messages¶
| Event | Topic | Scope / v | Aggregate | Consumers | Summary |
|---|---|---|---|---|---|
| ApprovalRuleActivated | approval-policy.rules.v1.approval-rule-activated |
integration / 1 | ApprovalRule | StudioBff, AuditService | A rule revision is in force. |
| ApprovalRouted | approval-policy.routing.v1.approval-routed |
integration / 1 | RoutingEvaluation | StudioNotificationService, DashboardService, AuditService | A request was routed to eligible approvers (or found none). |
| ApprovalDecisionValidated | approval-policy.routing.v1.approval-decision-validated |
integration / 1 | RoutingEvaluation | AuditService, DashboardService | A decision was validated or found invalid. |
ApprovalRuleActivated payload:
| Field | Type | Required | Description |
|---|---|---|---|
ruleId |
string |
yes | |
tenantId |
string |
yes | |
scope |
string |
yes | |
actionClass |
string |
yes | |
policyRevision |
string |
yes |
ApprovalRouted payload:
| Field | Type | Required | Description |
|---|---|---|---|
evaluationId |
guid |
yes | |
approvalId |
guid |
yes | |
tenantId |
string |
yes | |
outcome |
enum:RoutingOutcome |
yes | |
expiresAt |
datetime |
yes |
ApprovalDecisionValidated payload:
| Field | Type | Required | Description |
|---|---|---|---|
evaluationId |
guid |
yes | |
approvalId |
guid |
yes | |
tenantId |
string |
yes | |
deciderId |
string |
yes | |
outcome |
enum:RoutingOutcome |
yes | |
breakGlass |
bool |
yes |
| Command | Handled by | Result events | Summary |
|---|---|---|---|
Route |
RoutingEvaluation | ApprovalRouted | |
ValidateDecision |
RoutingEvaluation | ApprovalDecisionValidated |
| Event | Producer | Handler | Purpose |
|---|---|---|---|
| PolicyRevisionActivated | PolicyEngineService | LoadApprovalRules |
Approval rules are part of the policy bundle revision. |
| MembershipChanged | ProjectService | InvalidateEligibilityCache |
Re-evaluate eligibility when roles change. |
| DelegationChanged | ProjectService | InvalidateEligibilityCache |
Delegations change who may approve (D18). |
Workers and jobs¶
None modelled
No workers are modelled for this item yet.
Actors and grains¶
None modelled
No grains are modelled for this item yet.
Sagas and long-running flows¶
None modelled
No sagas are modelled for this item yet.
sequenceDiagram
participant RC as RunControlService
participant AP as ApprovalPolicyService
participant S as Studio inbox
participant R as Reviewer
RC->>AP: Route (action class, scope, requester, author)
AP-->>RC: rule, eligible approvers, expiry
RC-)S: ApprovalRequested
R->>RC: decision with request version
RC->>AP: ValidateDecision (decider, request)
AP->>AP: eligible now, not requester, not author, unexpired, quorum
AP-->>RC: Eligible or Invalid with reason
Options and configuration¶
| Section | Key | Type | Default | Reloadable | Secret | Summary |
|---|---|---|---|---|---|---|
Governance:Approvals |
MaxExpiryMinutes |
int | 4320 |
yes | no | Platform maximum for approval expiry (72 h). |
Governance:Approvals |
BreakGlassRequiresReason |
bool | true |
no | no | Break-glass decisions need a reason and are always audited. |
Metrics and observability¶
| Metric | Instrument | Unit | Dimensions | Summary |
|---|---|---|---|---|
connectsoft.factory.approval_policy.routings |
counter | {routing} | tenant, actionClass, outcome | Routings by outcome. |
connectsoft.factory.approval_policy.invalid_decisions |
counter | {decision} | tenant, outcome | Rejected decisions by reason. |
connectsoft.factory.approval_policy.break_glass |
counter | {decision} | tenant | Break-glass decisions. |
Security¶
| Authentication | client-credentials |
| Scopes | factory.policies.read (Read rules.); factory.policies.write (Validate project rules.); factory.approvals.read (Routing of a request.) |
| Roles | Reviewer, WorkspaceAdmin, PlatformAdmin, Observer |
| Tenant isolation | row-filter |
| Data classification | restricted |
| Max side-effect class | sandbox |
| Secrets | — |
Dependencies¶
Outbound
| Target | Kind | Purpose | Failure mode | Required |
|---|---|---|---|---|
| PolicyEngineService | event | Rule revisions. | queue | yes |
| ProjectService | grpc | Memberships and delegations for eligibility. | fail-fast | yes |
| ext-identity-platform | sync-http | Platform admin claims and groups. | fail-fast | yes |
Inbound
| Target | Kind | Purpose | Failure mode | Required |
|---|---|---|---|---|
| RunControlService | grpc | Route at request time; ValidateDecision at decision time. | — | yes |
| StudioBff | sync-http | Rule and routing views. | — | yes |
Failure and recovery¶
| Scenario | Detection | Recovery | Data impact | RTO (min) |
|---|---|---|---|---|
| No eligible approver for a class in a project | routing outcome NoEligibleApprover | request stays Pending with a Studio task for the Workspace admin to grant a Reviewer; expiry fails the run | none | 0 |
| Membership revoked while a decision is pending | eligibility re-check | decision Invalid; request re-routed | none | 0 |
NFR targets¶
| Target | Class value (pilot-interactive) | Override |
|---|---|---|
| latency_p95_ms | 2000 | — |
| availability_pct | 99.5 | — |
| throughput_rps | 50 | — |
| rpo_minutes | 15 | — |
| rto_minutes | 240 | — |
| cost_class | low | — |
Deployment¶
| Granularity | module of host · host GovernanceHost |
| Topologies | saas-default, hybrid, on-prem, hybrid-compute |
| Runtime | —; replicas —–— |
| Ports | — |
Current implementation¶
Nothing exists; the Config Platform prototype (September 2026) demonstrates digest-bound approvals, separation of duties and a grant matrix in the UI and is the UX reference for this service.
Later capabilities¶
Quorum approvals with per-item outcomes for bulk decisions; time-of-day and on-call routing; approval SLAs with escalation.
Decisions and traceability¶
| Family | Ids |
|---|---|
| FR | FR-06 |
| FUX | FUX-05 |
| NFR | NFR-04 |
| XL | XL-05, XL-02 |