riskguard

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: MIT Imports: 26 Imported by: 0

README

kite-mcp-riskguard

Go Reference

Pre-trade risk safety controls for the algo2go ecosystem. 8+ checks gate every order before it reaches the broker: kill switch, order-value cap, qty cap, daily count limit, per-second/per-minute rate limit, duplicate detection, daily-value cap, auto-freeze circuit breaker, OTR (Order-to-Trade Ratio) band check, market-hours guard, margin check, and plugin-discovery subprocess checks for custom extensions.

Used by Sundeepg98/kite-mcp-server as the gate between Audit and Elicitation in the order execution chain: Audit -> Riskguard -> Elicitation -> Kite API.

Why a separate module?

Risk gating is a foundational safety primitive that any algo2go consumer placing orders should use independently of kite-mcp-server's broker integration. Hosting as its own module:

  • Centralizes the Guard contract + 8+ check implementations
  • Lets check thresholds + rate-limit policies version independently
  • Decouples plugin-discovery subprocess hooks from any one runtime
  • Provides a stable RPC contract (checkrpc/) for external check plugins written in any language

Stability promise

v0.x — unstable. Pin v0.1.0 deliberately.

Install

go get github.com/algo2go/kite-mcp-riskguard@v0.1.0

Public API

  • Guard — orchestrates all checks; main entry point
  • KillSwitch, CircuitLimit, Limits, Trackers, PerSecond, Dedup, MarginCheck, MarketHours, OTRBand — individual checks
  • SubprocessCheck — plugin-discovery + RPC for external check plugins
  • Middleware — Guard wrapped as a middleware for use-case chains
  • checkrpc/ — RPC types for external plugins (zero algo2go deps, embeddable in any language binding)

Dependencies

  • github.com/algo2go/kite-mcp-alerts v0.1.0
  • github.com/algo2go/kite-mcp-domain v0.1.0
  • github.com/algo2go/kite-mcp-i18n v0.1.0
  • github.com/algo2go/kite-mcp-logger v0.1.0
  • github.com/algo2go/kite-mcp-oauth v0.1.0
  • github.com/hashicorp/go-plugin — RPC plugin framework
  • github.com/stretchr/testify v1.10.0

All algo2go deps published; no upstream replace directives needed.

Reference consumer

Sundeepg98/kite-mcp-server — consumed across 53 .go files: kc/manager_, kc/options.go, kc/config.go, kc/broker_services.go, kc/ports/order.go, kc/papertrading/, kc/usecases/, kc/telegram/, kc/ops/, app/, mcp/admin/, mcp/middleware/, mcp/common/, mcp/misc/, mcp/tools_*_test.go, examples/riskguard-check-plugin/main.go.

License

MIT — see LICENSE.

Authors

Original design: Sundeepg98 (Zerodha Tech). Multi-module promotion (2026-05-10): algo2go contributors.

Documentation

Index

Constants

View Source
const (
	OrderKillSwitch           = 100  // user trading frozen
	OrderConfirmationRequired = 200  // explicit confirm=true required
	OrderOrderValue           = 300  // per-order notional cap
	OrderQuantityLimit        = 400  // exchange freeze-quantity cap
	OrderDailyOrderCount      = 500  // per-day order-count cap
	OrderPerSecondRate        = 600  // 9-orders-per-calendar-second (SEBI)
	OrderRateLimit            = 700  // per-minute order rate
	OrderClientOrderIDDup     = 800  // user-supplied idempotency key
	OrderDuplicateOrder       = 900  // time-based params-hash duplicate
	OrderDailyValue           = 1000 // per-day cumulative notional
	OrderAnomalyMultiplier    = 1100 // rolling baseline statistical anomaly
	OrderOffHours             = 1200 // 02:00–06:00 IST hard-block
	OrderMarketHours          = 1300 // T1: NSE/BSE market-hours rejection (AMO bypasses)
)

Built-in check order constants. Spaced in increments of 100 so plugins can interleave at intermediate positions without having to renumber built-ins. Values are stable public API — changing one is a semver-relevant behaviour shift because it re-orders rejection precedence for callers relying on which reason surfaces first when multiple rules would reject.

View Source
const (
	OTRBandOptionsPct     = 0.40   // ±40% for equity options
	OTRBandCashFuturesPct = 0.0075 // ±0.75% for cash + futures
)

OTR exemption band thresholds. Single source of truth for the SEBI rule values; tests and production both read these constants.

View Source
const DefaultDedupTTL = 15 * time.Minute

DefaultDedupTTL is the default idempotency window for client_order_id deduplication. 15 minutes covers mcp-remote retry-after-504 scenarios and network reconnect storms while keeping memory bounded.

View Source
const OrderCircuitLimit = 350

OrderCircuitLimit is the Check.Order slot for the circuit rule.

View Source
const OrderMarginCheck = 325

OrderMarginCheck is the Check.Order slot.

View Source
const OrderOTRBand = 1050

OrderOTRBand is the Check.Order slot for the band check. Sits after the daily-value cap and before the anomaly detector — placed late so cheap reject-paths short-circuit ahead of the LTP lookup.

Variables

View Source
var SystemDefaults = UserLimits{
	MaxSingleOrderINR:       domain.NewINR(50000),
	MaxOrdersPerDay:         20,
	MaxOrdersPerMinute:      10,
	DuplicateWindowSecs:     30,
	MaxDailyValueINR:        domain.NewINR(200000),
	AutoFreezeOnLimitHit:    true,
	RequireConfirmAllOrders: true,
}

System defaults — overridable via env vars or per-user DB config.

SECURITY NOTE (Free-tier mitigation for prompt-injection / market-manipulation): These values were tightened to address the "sub-cap layering" landmine — an adversarial prompt that places many small orders under the per-order cap but whose cumulative notional still looks like manipulative layering to SEBI surveillance. The previous defaults (Rs 5L cap, 200/day, Rs 10L notional) were too permissive for an autonomous agent execution path. Power users can still raise their limits per-account via risk_limits overrides.

Functions

func DiscoverPlugins

func DiscoverPlugins(dir string, registrar PluginRegistrar, logger *slog.Logger) error

DiscoverPlugins reads `<dir>/plugins.json` and invokes registrar for each entry. Provides the runtime "discovery" half of the already-shipped subprocess plugin framework: operators drop a manifest file in dir, restart the server, and their plugins load without source-code changes.

Failure modes:

  • dir does not exist → no-op, returns nil. (Most operators have no plugin directory; treating that as an error would be noisy.)
  • dir exists but no plugins.json → no-op, returns nil.
  • plugins.json is malformed → returns wrapped JSON parse error.
  • registrar returns an error for one entry → DiscoverPlugins continues to the remaining entries (best-effort), then returns an aggregated error via errors.Join. This matches the operator expectation that one broken plugin should not silently block other plugins from loading.

Logger is optional; when non-nil, each successful registration is logged at Info level so operators can confirm what loaded.

Concurrency: not safe for concurrent calls against the same registrar. The expected usage is "called once at app startup, before tool handlers begin serving" — same lifecycle as the rest of the riskguard registration surface.

func IsOrderTool

func IsOrderTool(name string) bool

IsOrderTool returns true if the tool should be risk-checked.

func Middleware

func Middleware(guard *Guard) server.ToolHandlerMiddleware

Middleware returns an MCP tool handler middleware that runs risk checks before order tools execute. Non-order tools pass through immediately.

func PinClockToMarketHoursForTest

func PinClockToMarketHoursForTest(g *Guard)

PinClockToMarketHoursForTest is a cross-package test helper that pins g.clock() to the most recent weekday at 10:30 IST so the market_hours (T1) and off_hours (02:00–06:00 IST) checks pass deterministically on weekend or deep-night CI runs. Use from any test package that constructs a Guard via NewGuard and exercises the order-checking path.

The pin rolls back to Friday on Sat/Sun so a test that records relative timestamps via `time.Now()` (DayResetAt, RecentOrders, etc.) still sees the same date the guard sees — preserving relative-time semantics that the day-reset boundary tests rely on.

Naming includes "ForTest" so it is unambiguous in IDE/godoc that this is a fixture utility, not a production setter. Production code paths MUST NOT call this — they should leave g.clock at its time.Now default.

Types

type AutoFreezeNotifier

type AutoFreezeNotifier func(email, reason string)

AutoFreezeNotifier is called when the circuit breaker auto-freezes a user. Implementations should be non-blocking (e.g. send async Telegram message).

type BaselineProvider

type BaselineProvider interface {
	UserOrderStats(email string, days int) (mean, stdev, count float64)
}

BaselineProvider returns rolling order-value statistics for a user. The production implementation is audit.Store.UserOrderStats, which queries the tool_calls table for historical place_order/modify_order rows and computes mean + population-stdev over the window. This interface lets tests inject fakes without pulling in the audit package (which would create a circular dependency) and keeps riskguard narrowly dependent on the one method it needs.

Contract: when the user has fewer than the store's minimum history threshold (currently 5 rows), the provider MUST return (0, 0, count) so the guard knows to skip the anomaly check rather than treat the empty baseline as "all orders are infinitely anomalous".

type Check

type Check interface {
	// Name is a stable identifier for logs, metrics, and admin listings.
	// Use snake_case; must be unique within a Guard's registered set.
	Name() string

	// Order determines evaluation position (ascending). Built-in checks
	// occupy 100..1200 in steps of 100, so plugins can interleave at
	// 150, 250, ... without displacing built-ins. Use values <0 to run
	// before every built-in, or >1200 to run after all built-ins.
	Order() int

	// RecordOnRejection reports whether a not-allowed result should be
	// counted toward the auto-freeze circuit-breaker (3 rejections in
	// 5 min → freeze). Policy checks (kill-switch, confirmation gate)
	// return false; limit checks return true. Custom checks typically
	// return true.
	RecordOnRejection() bool

	// Evaluate runs the rule against req. A result with Allowed=true
	// passes the rule; Allowed=false short-circuits the chain and is
	// surfaced to the caller.
	Evaluate(req OrderCheckRequest) CheckResult
}

Check is the interface for a single pre-trade risk rule.

Checks are evaluated in Order() ascending; the first check to return a CheckResult with Allowed=false wins and short-circuits the chain. This matches the historical sequential if-chain behaviour in CheckOrder, so converting a hard-coded check to a Check-interface type is purely a structural refactor — no semantic change.

The interface is intentionally narrow so third-party plugins (e.g. a premium-tier "no-options-near-expiry" check, a custom options-strike block, a compliance-team approval gate) can register new checks via Guard.RegisterCheck without touching the riskguard package.

RecordOnRejection distinguishes "policy rejections" (kill-switch, confirmation-required) that must NOT count toward the auto-freeze circuit breaker from "limit violations" (order-value, quantity, rate, daily-value, anomaly, off-hours) that DO count. Preserving this distinction keeps the existing auto-freeze semantics exact: a user whose trading is already frozen does not get "auto-frozen again" and a user who simply forgot to confirm isn't penalised toward an eventual lockout.

type CheckResult

type CheckResult struct {
	Allowed bool
	Reason  RejectionReason
	Message string
}

CheckResult is returned by the guard for every order attempt.

type CircuitLookup

type CircuitLookup interface {
	GetCircuitLimits(exchange, tradingsymbol string) (lower, upper float64, found bool)
}

CircuitLookup is the narrow port the circuit check needs. Production binds this to a thin wrapper over broker.Quote responses cached in the LTP cache; tests pass a stub map. Returns (lower, upper, found).

found=false signals "no data" — the check falls open. Better to pass a real order than reject one because the cache hasn't warmed.

type Dedup

type Dedup struct {
	// contains filtered or unexported fields
}

Dedup provides user-scoped idempotency-key based deduplication for order submissions. It complements the existing time-based duplicate detection (same symbol/qty within 30s) by letting clients supply an explicit client_order_id — the same key submitted twice within TTL is rejected, regardless of parameter changes.

Design mirrors Alpaca's client_order_id pattern: user supplies an opaque string, server hashes (email || key) via SHA-256 to normalise length and avoid storing plaintext user identifiers in the in-memory map, then records the insertion timestamp for TTL-based expiry.

The implementation is in-memory only — it is process-local and does not survive a restart. This is acceptable because (a) mcp-remote retries happen within seconds of the initial 504, well inside a single process lifetime, and (b) the SQLite-backed time-based duplicate check continues to provide a fallback defence against restart-window replays.

func NewDedup

func NewDedup(ttl time.Duration) *Dedup

NewDedup creates a new Dedup with the given TTL. If ttl is zero or negative, DefaultDedupTTL is used.

func (*Dedup) Cleanup

func (d *Dedup) Cleanup()

Cleanup removes entries older than TTL. Safe to call periodically; a background goroutine can drive this but is not required — the map is bounded by concurrent-order-rate * TTL, which is small for this workload (handful of orders per minute).

func (*Dedup) SeenOrAdd

func (d *Dedup) SeenOrAdd(email, clientOrderID string) bool

SeenOrAdd records (email, clientOrderID). Returns true if the key was already present and still within TTL (i.e. this is a duplicate), false if it was unseen (i.e. the call is the authoritative "first" submission and has now been recorded).

Stale entries (older than TTL) are treated as unseen and overwritten, so a key can be reused after expiry. Lazy cleanup of adjacent stale entries keeps the map bounded under steady-state use; call Cleanup() explicitly if a sweep is desired.

func (*Dedup) SetClock

func (d *Dedup) SetClock(c func() time.Time)

SetClock overrides the time source (for testing).

func (*Dedup) Size

func (d *Dedup) Size() int

Size returns the current number of tracked entries (including stale ones not yet swept). Primarily for testing and metrics.

type FreezeQuantityLookup

type FreezeQuantityLookup interface {
	GetFreezeQuantity(exchange, tradingsymbol string) (uint32, bool)
}

FreezeQuantityLookup is an interface for looking up instrument freeze quantities. Implemented by instruments.Manager wrapper to avoid direct dependency.

type GlobalFreezeStatus

type GlobalFreezeStatus struct {
	IsFrozen bool      `json:"is_frozen"`
	FrozenBy string    `json:"frozen_by,omitempty"`
	Reason   string    `json:"reason,omitempty"`
	FrozenAt time.Time `json:"frozen_at,omitempty"`
}

GlobalFreezeStatus holds the current global freeze state with metadata.

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard is the central risk management engine.

func NewGuard

func NewGuard(logger *slog.Logger) *Guard

NewGuard creates a new Guard with system defaults. All built-in risk checks (kill switch, confirmation, order-value, quantity, daily count, per-second rate, per-minute rate, idempotency-key dup, time-based dup, daily value, anomaly, off-hours) are pre-registered in their canonical order — see check.go for the stable Order() constants and the builtinChecks() list. Additional checks can be wired via Guard.RegisterCheck before the guard sees its first order.

Wave D Phase 3 Package 2: the supplied *slog.Logger is converted to the kc/logger.Logger port at the boundary. Internal log calls use the port API; existing callers (app/wire.go, app/providers/riskguard.go, testutil/kcfixture/manager.go) continue to pass *slog.Logger unchanged. SetLoggerPort lets new callers supply a port-typed logger directly without re-wrapping.

func (*Guard) CheckOrderCtx

func (g *Guard) CheckOrderCtx(ctx context.Context, req OrderCheckRequest) CheckResult

CheckOrder evaluates the registered check chain in Order ascending. The first Check returning Allowed=false wins and short-circuits.

Built-in rule precedence (see Order* constants in check.go):

 100  kill_switch                (policy — no auto-freeze)
 200  confirmation_required      (policy — no auto-freeze)
 300  order_value                (limit)
 400  quantity_limit             (limit)
 500  daily_order_count          (limit)
 600  per_second_rate            (limit — SEBI Apr 2026 defence)
 700  rate_limit                 (limit — per-minute)
 800  client_order_id_duplicate  (limit — idempotency key)
 900  duplicate_order            (limit — time-based params hash)
1000  daily_value                (limit)
1100  anomaly_multiplier         (limit — rolling baseline)
1200  off_hours                  (limit — 02:00–06:00 IST)

Global freeze is evaluated BEFORE the chain because it blocks every user unconditionally and precedes any per-user check.

For any Check whose RecordOnRejection()==true, a rejection is logged to the user's recent-rejections sliding window; three such rejections within autoFreezeWindow trigger an auto-freeze. Policy checks (kill switch, confirmation gate) return false so they don't drive a user toward a lockout for an error they didn't make.

Wave D Phase 3 Package 2: CheckOrderCtx is the ctx-aware variant — middleware paths call it with the request ctx so panic-recovery log entries from safeEvaluate carry trace correlation. The legacy CheckOrder shim below preserves the no-ctx public API for existing callers; subsequent sweep packages migrate use-case sites (kc/usecases/, kc/papertrading/, kc/telegram/) to CheckOrderCtx.

func (*Guard) Freeze

func (g *Guard) Freeze(email, by, reason string)

Freeze freezes trading for a user.

func (*Guard) FreezeGlobal

func (g *Guard) FreezeGlobal(frozenBy, reason string)

FreezeGlobal activates a server-wide trading freeze that blocks ALL users.

Emits RiskguardKillSwitchTrippedEvent (Active=true) on a real off→on transition. Idempotent re-emission: a second FreezeGlobal call while already frozen is a no-op and emits no event, so projection replays see one trip per actual lifecycle. The dispatcher field is read under the same writer-lock that mutates the freeze state, then the actual Dispatch happens after the lock is released to avoid handlers running under the riskguard mutex.

func (*Guard) GetEffectiveLimits

func (g *Guard) GetEffectiveLimits(email string) UserLimits

GetEffectiveLimits returns the active limits for a user (per-user override or system default).

func (*Guard) GetGlobalFreezeStatus

func (g *Guard) GetGlobalFreezeStatus() GlobalFreezeStatus

GetGlobalFreezeStatus returns the current global freeze status.

func (*Guard) GetUserStatus

func (g *Guard) GetUserStatus(email string) UserStatus

GetUserStatus returns a snapshot of the user's current daily order count, placed value, and freeze state.

func (*Guard) InitTable

func (g *Guard) InitTable() error

InitTable creates the risk_limits table if it doesn't exist, and migrates new columns.

NOTE: the DEFAULT values below are the DB-side defaults used ONLY for rows that omit the column at INSERT time. persistLimits always supplies every column, so the authoritative Free-tier defaults are the Go-side SystemDefaults values in guard.go. The DDL defaults are kept aligned with SystemDefaults for consistency during forensic DB inspection.

func (*Guard) IsFrozen

func (g *Guard) IsFrozen(email string) bool

IsFrozen returns true if the user's trading is frozen.

func (*Guard) IsGloballyFrozen

func (g *Guard) IsGloballyFrozen() bool

IsGloballyFrozen returns true if the server-wide trading freeze is active.

func (*Guard) ListCheckNames

func (g *Guard) ListCheckNames() []string

ListCheckNames returns the Name() of every registered check in evaluation order. Intended for admin tooling ("show me the active risk chain"), audit, and tests. Snapshot: safe for concurrent use with RegisterCheck, but the returned slice is a copy.

func (*Guard) LoadLimits

func (g *Guard) LoadLimits() error

LoadLimits loads per-user limits from the database into memory.

func (*Guard) RecordOrder

func (g *Guard) RecordOrder(email string, req ...OrderCheckRequest)

RecordOrder records a successful order for all tracking: daily count, rate window, duplicate detection, and daily value.

When maybeResetDay rolls the trading-day boundary (DayResetAt < today's 9:15 IST), a RiskguardDailyCounterResetEvent is dispatched AFTER the lock is released so handlers don't run under the riskguard mutex — dispatcher captured under the writer-lock for snapshot consistency.

func (*Guard) RegisterCheck

func (g *Guard) RegisterCheck(c Check)

RegisterCheck installs a Check into the ordered chain. Safe to call concurrently with CheckOrder — the writer locks mu, and the reader path takes a snapshot under a reader-lock. Duplicate Name() values are allowed (last-registered wins at its Order position) but not encouraged; use distinct snake_case names to keep logs searchable.

Typical usage: called once during app wiring, before the MCP server accepts its first tool call. Plugins that want to add a check dynamically (e.g. per-user) should still register at startup and gate inside Evaluate() on req.Email.

func (*Guard) RegisterSubprocessCheck

func (g *Guard) RegisterSubprocessCheck(name, executable string, order int) error

RegisterSubprocessCheck is the Guard-side ergonomic wrapper: construct a SubprocessCheck from minimal config and register it in the Check chain. Also computes a SHA-256 checksum of the plugin binary on disk and emits it to the caller via the returned ChecksumEmitter — the caller can stitch that into the mcp.PluginSBOM registry without creating a dependency cycle (riskguard MUST NOT import mcp).

Returns an error for invalid config; otherwise the subprocess check is installed at the given Order slot. The subprocess is NOT launched yet — first Evaluate triggers launch.

Plugin authors' workflow:

  1. Write a Go program implementing checkrpc.CheckRPC.
  2. Serve it via hashicorp/go-plugin.Serve (see examples/riskguard-check-plugin/main.go).
  3. Build: `go build -o myplugin .`
  4. Register on the Guard: g.RegisterSubprocessCheck("my_plugin", "/path/to/myplugin", 2500)

Reload: to ship a new version, rebuild the binary — the NEXT failed RPC call tears down the cached client; the call after that relaunches from disk and picks up the new binary. No server restart required.

func (*Guard) RegisterSubprocessCheckWithSBOM

func (g *Guard) RegisterSubprocessCheckWithSBOM(
	name, executable string,
	order int,
	emit SubprocessChecksumEmitter,
) error

RegisterSubprocessCheckWithSBOM is the SBOM-aware variant of RegisterSubprocessCheck. Computes a SHA-256 of the plugin binary and passes (name, executable, "sha256:<hex>", nil) to the emitter before the check is registered. If the binary can't be hashed, invokes the emitter with an empty checksum + the error, then proceeds with registration anyway — a missing binary will fail-closed at Evaluate time (see SubprocessCheck.failClosed).

func (*Guard) SetAutoFreezeNotifier

func (g *Guard) SetAutoFreezeNotifier(fn AutoFreezeNotifier)

SetAutoFreezeNotifier registers a callback invoked when the circuit breaker auto-freezes a user. The callback receives the user email and the freeze reason.

func (*Guard) SetBaselineProvider

func (g *Guard) SetBaselineProvider(p BaselineProvider)

SetBaselineProvider wires the rolling-baseline source used by the anomaly check. Optional: when nil, checkAnomalyMultiplier is a silent no-op, which is the correct behaviour for DevMode / tests without an audit store.

func (*Guard) SetCircuitLookup

func (g *Guard) SetCircuitLookup(lookup CircuitLookup)

SetCircuitLookup wires the T2 circuit-band oracle. When non-nil, circuitLimitCheck consults the lookup on every LIMIT order to verify the price is inside the exchange-set daily band. nil → check no-ops.

func (*Guard) SetClock

func (g *Guard) SetClock(c func() time.Time)

SetClock overrides the time source (for testing).

func (*Guard) SetDB

func (g *Guard) SetDB(db *alerts.DB)

SetDB sets the SQLite database for persisting risk limits.

func (*Guard) SetEventDispatcher

func (g *Guard) SetEventDispatcher(d *domain.EventDispatcher)

SetEventDispatcher wires the domain event dispatcher so the counters aggregate emits typed Riskguard*Event values on its mutation surface (kill-switch trip/lift, daily-counter reset, rejection recorded).

Pattern mirrors billing.Store.SetEventDispatcher / watchlist use cases' SetEventDispatcher: nil-safe (a Guard constructed without one behaves identically to the pre-ES code path), idempotent (re-calling replaces the dispatcher), and write-only from the riskguard side — the dispatcher's own subscribers handle persistence via makeEventPersister in app/wire.go. Acquires the writer-lock so concurrent CheckOrder paths see a consistent snapshot.

func (*Guard) SetFreezeQuantityLookup

func (g *Guard) SetFreezeQuantityLookup(lookup FreezeQuantityLookup)

SetFreezeQuantityLookup sets the instrument lookup for quantity checks.

func (*Guard) SetLTPLookup

func (g *Guard) SetLTPLookup(lookup LTPLookup)

SetLTPLookup wires the SEBI OTR-band check's LTP oracle. When non-nil, otrBandCheck consults this on every priced (LIMIT/SL) order to verify the price is inside the asset-class exemption band. nil means the band check no-ops (acceptable in DevMode / tests where no live LTP stream is wired).

func (*Guard) SetLoggerPort

func (g *Guard) SetLoggerPort(l logport.Logger)

SetLoggerPort assigns a structured logger via the kc/logger.Logger port. Preferred over the implicit-conversion-in-NewGuard path for callers that already work in port-typed terms (Phase 2 providers).

Wave D Phase 3 Package 2. Nil-safe: the internal logger field is nil-checked at every use site (matches pre-migration semantics).

func (*Guard) SetMarginCheckEnabled

func (g *Guard) SetMarginCheckEnabled(enabled bool)

SetMarginCheckEnabled toggles the T5 pre-trade margin check. Default is FALSE (opt-in per the brief). Production wiring may flip it true globally or per-tier; tests use it to scope a guard.

func (*Guard) SetMarginLookup

func (g *Guard) SetMarginLookup(lookup MarginLookup)

SetMarginLookup wires the T5 pre-trade margin oracle. The lookup alone does NOT enable the check — call SetMarginCheckEnabled(true) separately. Splitting the two setters lets operators wire the margin source eagerly (benefits the dashboard / activity feed) without forcing pre-trade rejection on every user.

func (*Guard) SetServiceCtx

func (g *Guard) SetServiceCtx(ctx context.Context)

SetServiceCtx captures the parent application context used by goroutine + lifecycle log calls (FreezeGlobal, UnfreezeGlobal, persistLimits, the auto-freeze admin alert) that originate outside a request path. Without this, those log calls fall back to context.Background() — losing app-level trace correlation.

Wave D Phase 3 Package 2. Optional: a Guard constructed without SetServiceCtx degrades cleanly to context.Background().

func (*Guard) Unfreeze

func (g *Guard) Unfreeze(email string)

Unfreeze unfreezes trading for a user.

func (*Guard) UnfreezeGlobal

func (g *Guard) UnfreezeGlobal()

UnfreezeGlobal lifts the server-wide trading freeze.

Emits RiskguardKillSwitchTrippedEvent (Active=false) on a real on→off transition. Idempotent: unfreeze when already unfrozen is a no-op and emits no event.

type LTPLookup

type LTPLookup interface {
	// GetLTP returns the last traded price for the given instrument
	// and a found-flag. found=false signals "no quote available" —
	// the check will pass through without rejecting.
	GetLTP(exchange, tradingsymbol string) (price float64, found bool)
}

LTPLookup is the narrow port the OTR band check needs to fetch the last-traded-price for a given instrument. Production binds this to the instruments-cache or paper-trading LTP provider. nil-implementation is allowed; the check no-ops in that case rather than block valid orders on missing infrastructure.

type MarginLookup

type MarginLookup interface {
	// GetAvailableMargin returns the user's available margin (across
	// equity + commodity, summed). Single value rather than per-segment
	// keeps the interface minimal — the check's notional comparison
	// doesn't need per-segment routing yet.
	GetAvailableMargin(email string) (float64, bool)
}

MarginLookup is the narrow port the margin check needs. Production binds this to a periodically-refreshed GetMargins cache; tests stub.

found=false → "no margin data" → check falls open.

type OrderCheckRequest

type OrderCheckRequest struct {
	Email           string
	ToolName        string
	Exchange        string
	Tradingsymbol   string
	TransactionType string // BUY or SELL
	Quantity        int
	Price           domain.Money // zero Money for MARKET orders
	OrderType       string       // MARKET, LIMIT, SL, SL-M
	// Confirmed indicates the user explicitly acknowledged this order (e.g.
	// replied `confirm: true` to an elicitation). When
	// UserLimits.RequireConfirmAllOrders is true, orders without Confirmed=true
	// are rejected with ReasonConfirmationRequired. Wire-set by the middleware
	// from request arguments; direct callers of CheckOrder must set this
	// themselves.
	Confirmed bool
	// ClientOrderID is an optional user-supplied idempotency key (Alpaca-style
	// client_order_id). When present, the guard hashes (email || key) and
	// rejects any duplicate submission within DefaultDedupTTL (15 min) with
	// ReasonDuplicateOrder. Empty means "no idempotency semantics" —
	// backward-compatible with the existing time-based duplicate check.
	ClientOrderID string
	// Variety is the Kite order variety (regular/amo/co/iceberg/auction).
	// Currently consulted only by checkMarketHours: variety="amo" bypasses
	// the [09:15, 15:30) IST market-hours block because AMO orders are
	// queued for the next session by Kite's OMS. Empty defaults to
	// "regular" semantics (i.e. NOT an AMO bypass).
	Variety string
}

OrderCheckRequest contains the data needed to evaluate an order.

Price is typed domain.Money (Slice 2 of the Money sweep). The zero value (Money{}) is the "MARKET order / no price set" sentinel — every priced check (order_value, daily_value, OTR-band, circuit limit, margin) skips when Price.IsZero() returns true. Construct via domain.NewINR(price) at every call site.

type PluginDiscoveryEntry

type PluginDiscoveryEntry struct {
	Name       string `json:"name"`
	Executable string `json:"executable"`
	Order      int    `json:"order"`
}

PluginDiscoveryEntry is one row of the plugin manifest. It maps a human-readable name to the binary that implements the checkrpc.CheckRPC interface for the host's hashicorp/go-plugin transport.

The manifest is intentionally minimal: name, executable, order. SBOM emission, RecordOnRejection, and Args are deferred to the runtime configuration that wraps DiscoverPlugins (operator can call RegisterSubprocessCheckWithSBOM directly for those concerns). The 99% case is "I have a plugin binary at /path; load it" — that's what this surface optimises for.

type PluginRegistrar

type PluginRegistrar func(name, executable string, order int) error

PluginRegistrar is the callback that DiscoverPlugins invokes for each manifest entry. The host typically passes Guard.RegisterSubprocessCheck — that signature matches.

type RejectionReason

type RejectionReason string

RejectionReason categorizes why an order was blocked.

const (
	ReasonGlobalFreeze    RejectionReason = "global_freeze"
	ReasonTradingFrozen   RejectionReason = "trading_frozen"
	ReasonOrderValue      RejectionReason = "order_value_limit"
	ReasonQuantityLimit   RejectionReason = "quantity_limit"
	ReasonDailyOrderLimit RejectionReason = "daily_order_limit"
	ReasonRateLimit       RejectionReason = "rate_limit"
	ReasonDuplicateOrder  RejectionReason = "duplicate_order"
	ReasonDailyValueLimit RejectionReason = "daily_value_limit"
	ReasonAutoFreeze      RejectionReason = "auto_freeze"
	// ReasonConfirmationRequired blocks silent auto-execution by an agent — the
	// caller must explicitly set Confirmed=true on the OrderCheckRequest (which
	// the middleware populates from a `confirm: true` tool argument the user
	// acknowledged via elicitation).
	ReasonConfirmationRequired RejectionReason = "confirmation_required"
	// ReasonAnomalyHigh fires when an order is simultaneously > μ+3σ AND
	// > 10×μ on the user's rolling 30-day baseline. Catches the "user who
	// typically places Rs 5k orders suddenly places Rs 49k" pattern that
	// slips under static per-order caps but is statistically impossible
	// given their trading history — prompt injection or account takeover.
	ReasonAnomalyHigh RejectionReason = "anomaly_high"
	// ReasonOffHoursBlocked fires on any order placed between 02:00 and 06:00
	// IST. Outside market hours AND outside the typical human decision window,
	// so any activity there is either automation gone wrong or an adversary
	// betting the account owner is asleep. Power users can opt out via
	// UserLimits.AllowOffHours.
	ReasonOffHoursBlocked RejectionReason = "off_hours_blocked"
	// ReasonOTRBand fires when the order's price is outside the SEBI
	// OTR exemption band around LTP. ±0.75% for cash + futures, ±40%
	// for equity options (SEBI circular Feb 2026, effective Apr 6 2026).
	// Catches fat-finger trades and keeps the user's OTR ratio clean
	// without depending on the broker side throttling.
	ReasonOTRBand RejectionReason = "otr_band_violation"
	// ReasonCircuitBreached fires when the order's LIMIT price is
	// outside the exchange-set daily circuit band (typically ±5/10/20%
	// of previous-day close). Pre-T2 we relied on Kite to surface this;
	// catching it client-side avoids a round trip + per-second rate
	// pressure for a guaranteed-rejection.
	ReasonCircuitBreached RejectionReason = "circuit_breached"
	// ReasonInsufficientMargin fires when the optional pre-trade
	// margin check (T5) computes notional > available. NOT a fat-
	// finger or abuse signal — a legitimate margin exhaustion from
	// prior fills produces this. RecordOnRejection=false on the
	// check so it doesn't trigger auto-freeze.
	ReasonInsufficientMargin RejectionReason = "insufficient_margin"
	// ReasonMarketClosed fires on any non-AMO order placed outside
	// NSE/BSE equity-cash market hours (weekdays, [09:15, 15:30) IST).
	// Variety="amo" bypasses the check (next-session queue). T1 in the
	// gap catalogue. Holiday calendar is intentionally NOT enforced
	// client-side — Kite's OMS rejects holiday orders anyway, and
	// shipping a stale calendar would create false-rejects on every
	// SEBI-announced special session.
	ReasonMarketClosed RejectionReason = "market_closed"
)
const ReasonPerSecondRateExceeded RejectionReason = "per_second_rate_exceeded"

ReasonPerSecondRateExceeded fires when a user submits more than maxOrdersPerSecond order attempts within the same wall-clock calendar second. This sits strictly inside the broker-side SEBI threshold so the defensive cap always refuses before Zerodha does.

type SubprocessCheck

type SubprocessCheck struct {
	// contains filtered or unexported fields
}

SubprocessCheck implements the riskguard.Check interface by dispatching Evaluate() to a subprocess over hashicorp/go-plugin's netRPC transport. The subprocess is LAZILY launched on first Evaluate — construction is cheap and side-effect-free.

Thread safety: Evaluate is safe for concurrent callers. The mutex serialises subprocess launch AND cached-client replacement after a crash, but the actual RPC call is un-mutex'd so multiple goroutines can be in-flight against the single subprocess. (hashicorp/go-plugin's netRPC client is safe for concurrent use.)

func NewSubprocessCheck

func NewSubprocessCheck(cfg SubprocessCheckConfig) *SubprocessCheck

NewSubprocessCheck constructs a SubprocessCheck. No subprocess is spawned yet — the plugin is launched on the first Evaluate call.

func (*SubprocessCheck) Close

func (s *SubprocessCheck) Close()

Close tears down the subprocess. After Close the SubprocessCheck returns fail-closed from every Evaluate call — Close is terminal. Safe to call multiple times.

func (*SubprocessCheck) Evaluate

Evaluate dispatches the pre-trade check to the subprocess. On any of the following failure modes, returns Allowed=false (fail-closed) with Reason="subprocess_unavailable":

  • Close() has been called;
  • the plugin binary does not exist / is not executable;
  • the subprocess died between calls and relaunch failed;
  • the RPC call itself errored (transport failure).

A subprocess-side panic is converted by go-plugin into a broken-pipe RPC error on the next call; we catch that, log, and fail closed. The subprocess is relaunched on the NEXT evaluation attempt — no retry-in-place, because a plugin that crashed once is likely to crash again on the same input.

func (*SubprocessCheck) Name

func (s *SubprocessCheck) Name() string

Name returns the configured plugin name. Does NOT dispatch to the subprocess — the host-side name is authoritative and constant. This is safe to call before first launch.

func (*SubprocessCheck) Order

func (s *SubprocessCheck) Order() int

Order returns the configured Order value. See Name — host-side authoritative, no subprocess round-trip.

func (*SubprocessCheck) RecordOnRejection

func (s *SubprocessCheck) RecordOnRejection() bool

RecordOnRejection returns the configured flag. Host-side authoritative (plugin authors shouldn't change this at runtime).

type SubprocessCheckConfig

type SubprocessCheckConfig struct {
	// Name is the Check.Name() value. Used for health reporting,
	// logs, and manifest entries. Must be non-empty.
	Name string
	// Order is the Check.Order() value. Plugin authors pick a
	// slot that doesn't collide with built-in checks (100..1200).
	// Recommended: 2000+ so subprocess checks run AFTER all
	// built-ins, keeping built-in behaviour deterministic even
	// if the plugin is slow to respond.
	Order int
	// RecordOnRejection decides whether a plugin rejection counts
	// toward the auto-freeze circuit breaker. Default false —
	// plugin rejections are treated as policy signals, not limit
	// violations, until we have more experience with how plugin
	// authors use the hook.
	RecordOnRejection bool
	// Executable is the absolute path to the plugin binary. Must
	// be executable. A missing / broken binary fails CLOSED
	// (Evaluate returns Allowed=false) — see the fail-closed
	// comment on safeEvaluate in guard.go for the rationale.
	Executable string
	// Args are optional command-line arguments passed to the
	// plugin binary on launch (e.g., a config-file path).
	Args []string
	// Logger is the host-side logger. May be nil (logs are then
	// silent). The plugin subprocess gets its own hclog handle
	// managed by go-plugin.
	Logger *slog.Logger
}

SubprocessCheckConfig wires a subprocess-based Check into the riskguard chain. The plugin runs as a child process, communicating over netRPC via stdio pipes (managed by hashicorp/go-plugin). A crash in the plugin subprocess cannot corrupt host memory or kill the server — the worst case is one evaluation returns a subprocess_unavailable rejection and the host re-launches the plugin on the next call.

Why this matters:

  • third-party / user-authored checks can be iterated on without rebuilding the server binary (re-build the plugin binary and the next evaluation picks it up);
  • a plugin bug (nil deref, infinite loop, memory leak) is isolated to its own process — the host does not care;
  • the plugin binary is sandboxable at the OS level (cgroups, Windows Job Objects, seccomp) without the host having to opt into the same sandbox;
  • for regulated trading (SEBI Apr 2026 retail-algo), having each custom check run under the operator's OS isolation policy rather than in-process gives a clean audit story.

Performance: each Evaluate is one netRPC call over stdio. Measured latency on localhost is ~1-2ms — acceptable for the pre-trade path because riskguard's own chain already has 10-12 in-process checks adding similar per-call overhead.

func (SubprocessCheckConfig) Validate

func (c SubprocessCheckConfig) Validate() error

Validate checks the config for obvious authoring mistakes. Called by both NewSubprocessCheck and Guard.RegisterSubprocessCheck.

type SubprocessChecksumEmitter

type SubprocessChecksumEmitter func(name, executable, checksum string, err error)

SubprocessChecksumEmitter is the callback the caller supplies to capture the plugin-binary checksum for SBOM tracking. Kept as a narrow callback (not a direct import of mcp.RegisterPluginSBOM) so the riskguard package remains import-cycle-free.

Contract: emitter is invoked synchronously from RegisterSubprocessCheckWithSBOM before the SubprocessCheck is installed on the Guard. A nil emitter disables SBOM emission; a non-nil call with an empty checksum means the binary could not be hashed (file missing, permission error) — callers should treat that as degraded state.

type UserLimits

type UserLimits struct {
	MaxSingleOrderINR    domain.Money
	MaxOrdersPerDay      int
	MaxOrdersPerMinute   int
	DuplicateWindowSecs  int
	MaxDailyValueINR     domain.Money
	AutoFreezeOnLimitHit bool // when true, auto-freeze after repeated rejections
	// RequireConfirmAllOrders, when true, blocks any order that does not
	// carry an explicit Confirmed=true flag on the OrderCheckRequest. This
	// is the primary defence against silent prompt-injection auto-execution:
	// an agent cannot place an order on behalf of a user without that user
	// explicitly confirming (typically via MCP elicitation). A power user may
	// set this to false per-account to restore "no-ack" behaviour.
	RequireConfirmAllOrders bool
	// AllowOffHours, when true, lets this user trade during the 02:00–06:00
	// IST hard-block window. Default false for all new accounts — opt-in
	// escape hatch for power users running legitimate overnight automation.
	AllowOffHours bool
	TradingFrozen bool
	FrozenBy      string
	FrozenReason  string
	FrozenAt      time.Time
}

UserLimits holds configurable limits for a user.

Max*INR fields use the domain.Money value object (not bare float64) so the engine fails fast on cross-currency comparison rather than silently coercing. The zero value of Money (Amount=0, Currency="") is the "no per-user override" sentinel — GetEffectiveLimits replaces it with SystemDefaults at read time. New constructors must use domain.NewINR(N).

type UserStatus

type UserStatus struct {
	DailyOrderCount  int          `json:"daily_order_count"`
	DailyPlacedValue domain.Money `json:"-"`
	IsFrozen         bool         `json:"is_frozen"`
	FrozenBy         string       `json:"frozen_by"`
	FrozenReason     string       `json:"frozen_reason"`
	FrozenAt         time.Time    `json:"frozen_at,omitempty"`
}

UserStatus holds a snapshot of a user's current risk state for read-only reporting.

DailyPlacedValue is typed domain.Money internally but serialised as float64 via the custom MarshalJSON below so the dashboard SSE feed and admin tools see no behavioural change.

func (UserStatus) MarshalJSON

func (s UserStatus) MarshalJSON() ([]byte, error)

MarshalJSON emits DailyPlacedValue as a raw float64 to preserve the existing JSON wire contract (`{"daily_placed_value": 12345.67, ...}`). Internal callers should keep working with the Money value directly via the struct field; only JSON consumers see the primitive.

func (*UserStatus) UnmarshalJSON

func (s *UserStatus) UnmarshalJSON(data []byte) error

UnmarshalJSON reconstructs DailyPlacedValue as an INR Money from the wire-format float64. Used by tests and any admin tool that round-trips a UserStatus payload (e.g. fixtures captured from a running server).

type UserTracker

type UserTracker struct {
	DailyOrderCount  int
	DayResetAt       time.Time
	RecentOrders     []time.Time   // sliding window for rate limiting
	RecentParams     []recentOrder // sliding window for duplicate detection
	DailyPlacedValue domain.Money  // cumulative order value placed today
	RecentRejections []time.Time   // sliding window for circuit breaker auto-freeze
}

UserTracker holds in-memory per-user trading state.

DailyPlacedValue is typed domain.Money (Slice 3 of the Money sweep). Cumulative cap-checking happens via Money.Add / Money.GreaterThan; the JSON boundary (UserStatus, see below) drops to float64 via .Float64().

Directories

Path Synopsis
Package checkrpc carries the wire types and HashiCorp go-plugin handshake for subprocess-based riskguard Check plugins.
Package checkrpc carries the wire types and HashiCorp go-plugin handshake for subprocess-based riskguard Check plugins.
examples
riskguard-check-plugin command
riskguard-check-plugin is the reference implementation of a subprocess riskguard Check.
riskguard-check-plugin is the reference implementation of a subprocess riskguard Check.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL