Documentation
¶
Index ¶
- Constants
- Variables
- func DiscoverPlugins(dir string, registrar PluginRegistrar, logger *slog.Logger) error
- func IsOrderTool(name string) bool
- func Middleware(guard *Guard) server.ToolHandlerMiddleware
- func PinClockToMarketHoursForTest(g *Guard)
- type AutoFreezeNotifier
- type BaselineProvider
- type Check
- type CheckResult
- type CircuitLookup
- type Dedup
- type FreezeQuantityLookup
- type GlobalFreezeStatus
- type Guard
- func (g *Guard) CheckOrderCtx(ctx context.Context, req OrderCheckRequest) CheckResult
- func (g *Guard) Freeze(email, by, reason string)
- func (g *Guard) FreezeGlobal(frozenBy, reason string)
- func (g *Guard) GetEffectiveLimits(email string) UserLimits
- func (g *Guard) GetGlobalFreezeStatus() GlobalFreezeStatus
- func (g *Guard) GetUserStatus(email string) UserStatus
- func (g *Guard) InitTable() error
- func (g *Guard) IsFrozen(email string) bool
- func (g *Guard) IsGloballyFrozen() bool
- func (g *Guard) ListCheckNames() []string
- func (g *Guard) LoadLimits() error
- func (g *Guard) RecordOrder(email string, req ...OrderCheckRequest)
- func (g *Guard) RegisterCheck(c Check)
- func (g *Guard) RegisterSubprocessCheck(name, executable string, order int) error
- func (g *Guard) RegisterSubprocessCheckWithSBOM(name, executable string, order int, emit SubprocessChecksumEmitter) error
- func (g *Guard) SetAutoFreezeNotifier(fn AutoFreezeNotifier)
- func (g *Guard) SetBaselineProvider(p BaselineProvider)
- func (g *Guard) SetCircuitLookup(lookup CircuitLookup)
- func (g *Guard) SetClock(c func() time.Time)
- func (g *Guard) SetDB(db *alerts.DB)
- func (g *Guard) SetEventDispatcher(d *domain.EventDispatcher)
- func (g *Guard) SetFreezeQuantityLookup(lookup FreezeQuantityLookup)
- func (g *Guard) SetLTPLookup(lookup LTPLookup)
- func (g *Guard) SetLoggerPort(l logport.Logger)
- func (g *Guard) SetMarginCheckEnabled(enabled bool)
- func (g *Guard) SetMarginLookup(lookup MarginLookup)
- func (g *Guard) SetServiceCtx(ctx context.Context)
- func (g *Guard) Unfreeze(email string)
- func (g *Guard) UnfreezeGlobal()
- type LTPLookup
- type MarginLookup
- type OrderCheckRequest
- type PluginDiscoveryEntry
- type PluginRegistrar
- type RejectionReason
- type SubprocessCheck
- type SubprocessCheckConfig
- type SubprocessChecksumEmitter
- type UserLimits
- type UserStatus
- type UserTracker
Constants ¶
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.
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.
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.
const OrderCircuitLimit = 350
OrderCircuitLimit is the Check.Order slot for the circuit rule.
const OrderMarginCheck = 325
OrderMarginCheck is the Check.Order slot.
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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) FreezeGlobal ¶
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 ¶
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) IsGloballyFrozen ¶
IsGloballyFrozen returns true if the server-wide trading freeze is active.
func (*Guard) ListCheckNames ¶
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 ¶
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 ¶
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 ¶
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:
- Write a Go program implementing checkrpc.CheckRPC.
- Serve it via hashicorp/go-plugin.Serve (see examples/riskguard-check-plugin/main.go).
- Build: `go build -o myplugin .`
- 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) 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 ¶
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 ¶
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 ¶
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 ¶
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) 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 ¶
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 ¶
func (s *SubprocessCheck) Evaluate(req OrderCheckRequest) CheckResult
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 ¶
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().
Source Files
¶
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. |