Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/financial-incident-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Financial incident controls

Call `evaluateOperationalControl` immediately before every server-side domain
mutation. A missing control store fails closed for the protected write. Reads,
login, support and history stay available. Accepted webhooks must first be
stored durably; paused downstream processing is retried and never discarded.

Controls are narrow by operation and optional provider. Global pause/read-only
changes require a second administrator. Every activation, bypass, expiry and
recovery must use the security audit log with actor, reason and incident ID.
Bypass is reserved for already-accepted idempotent completion.

Provider adapters own a `ProviderCircuitBreaker`. When open they queue durable
work and return the safe public error. After the recovery interval a single
half-open probe is allowed; success closes the breaker and failure reopens it.

## Recovery checklist

1. Confirm the incident owner and reconcile durable webhook/payment receipts.
2. Test the affected provider with a non-financial recovery probe.
3. Move from paused to degraded and monitor errors and duplicate protection.
4. Re-enable the narrow operation, record the approver, and close the incident.
23 changes: 23 additions & 0 deletions lib/operations/controls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { strict as assert } from "node:assert"
import { evaluateOperationalControl, ProviderCircuitBreaker, validateControlChange, type OperationalControl } from "./controls"

const control: OperationalControl = {
id: "ctrl-1", version: 1, operation: "wallet.fund", state: "paused",
reason: "Provider incident", incidentId: "INC-7", actorId: "admin-a",
startsAt: new Date("2026-07-20T10:00:00Z"),
}
assert.equal(evaluateOperationalControl("wallet.fund", [control], { now: new Date("2026-07-20T11:00:00Z") }).allowed, false)
assert.equal(evaluateOperationalControl("wallet.debit", [control]).allowed, true)
assert.equal(evaluateOperationalControl("wallet.fund", null).code, "CONTROL_UNAVAILABLE")
assert.equal(evaluateOperationalControl("wallet.fund", [control], { idempotentCompletion: true }).allowed, true)

const breaker = new ProviderCircuitBreaker({ failureThreshold: 2, windowMs: 1_000, recoveryMs: 100 })
breaker.recordFailure(0)
breaker.recordFailure(1)
assert.equal(breaker.canRequest(50), false)
assert.equal(breaker.canRequest(101), true)
breaker.recordSuccess()
assert.equal(breaker.snapshot().state, "closed")

assert.throws(() => validateControlChange({ ...control, operation: "*", approvedBy: undefined }))
assert.doesNotThrow(() => validateControlChange({ ...control, operation: "*", approvedBy: "admin-b" }))
110 changes: 110 additions & 0 deletions lib/operations/controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
export const FINANCIAL_OPERATIONS = [
"wallet.fund", "wallet.debit", "investment.create", "down-payment.create",
"repayment.create", "payout.create", "kyc.upload", "account.link", "admin.adjust",
] as const
export type FinancialOperation = typeof FINANCIAL_OPERATIONS[number]
export type ControlState = "enabled" | "degraded" | "paused" | "read-only"

export interface OperationalControl {
id: string
version: number
operation: FinancialOperation | "*"
provider?: string
state: ControlState
reason: string
incidentId: string
actorId: string
approvedBy?: string
startsAt: Date
expiresAt?: Date
}

export interface ControlDecision {
allowed: boolean
state: ControlState
code?: "OPERATION_PAUSED" | "READ_ONLY" | "CONTROL_UNAVAILABLE"
message?: string
controlId?: string
}

export function evaluateOperationalControl(
operation: FinancialOperation,
controls: OperationalControl[] | null,
options: { provider?: string; now?: Date; idempotentCompletion?: boolean } = {}
): ControlDecision {
if (controls === null) {
return { allowed: false, state: "paused", code: "CONTROL_UNAVAILABLE", message: "This operation is temporarily unavailable." }
}
const now = options.now ?? new Date()
const active = controls
.filter((control) =>
(control.operation === operation || control.operation === "*") &&
(!control.provider || control.provider === options.provider) &&
control.startsAt <= now &&
(!control.expiresAt || control.expiresAt > now)
)
.sort((a, b) => impact(b.state) - impact(a.state) || b.version - a.version)[0]

if (!active || active.state === "enabled" || active.state === "degraded") {
return { allowed: true, state: active?.state ?? "enabled", controlId: active?.id }
}
if (options.idempotentCompletion) {
return { allowed: true, state: active.state, controlId: active.id }
}
return {
allowed: false,
state: active.state,
code: active.state === "read-only" ? "READ_ONLY" : "OPERATION_PAUSED",
message: "This financial operation is temporarily unavailable. Your existing records remain available.",
controlId: active.id,
}
}

function impact(state: ControlState) {
return { enabled: 0, degraded: 1, "read-only": 2, paused: 3 }[state]
}

export class ProviderCircuitBreaker {
private state: "closed" | "open" | "half-open" = "closed"
private failures: number[] = []
private openedAt?: number
constructor(
private readonly options = { failureThreshold: 5, windowMs: 60_000, recoveryMs: 30_000 }
) {}

canRequest(now = Date.now()) {
if (this.state === "open" && this.openedAt !== undefined && now - this.openedAt >= this.options.recoveryMs) {
this.state = "half-open"
}
return this.state !== "open"
}

recordSuccess() {
this.failures = []
this.openedAt = undefined
this.state = "closed"
}

recordFailure(now = Date.now()) {
this.failures = this.failures.filter((time) => now - time <= this.options.windowMs)
this.failures.push(now)
if (this.state === "half-open" || this.failures.length >= this.options.failureThreshold) {
this.state = "open"
this.openedAt = now
}
}

snapshot() {
return { state: this.state, failuresInWindow: this.failures.length, openedAt: this.openedAt }
}
}

export function validateControlChange(control: OperationalControl) {
if (!control.reason.trim() || !control.incidentId.trim()) throw new Error("reason and incident ID are required")
if (control.operation === "*" && control.state !== "enabled" && !control.approvedBy) {
throw new Error("global controls require maker-checker approval")
}
if (control.approvedBy && control.approvedBy === control.actorId) {
throw new Error("approver must differ from the control author")
}
}
Loading