Files
broker/pkg/pretrade/router_middleware.go
zeekay 7b09f3d3d0 feat(pretrade): G-37 synchronous pre-trade compliance gate
New pkg/pretrade implementing Gap G-37 (Stage 6.5): synchronous gate
at order-entry that fetches state from cap-table, transfer, AML, KYC,
and on-chain ERC-3643 compliance providers and decides allow / deny /
escalate. Each predicate is a composable internal function; the
Decision carries every actionable reason in a single round.

Wired into the broker order path via Wrap[Req, Resp] generic
middleware so existing router signatures stay untouched (additive,
forwards-only). GateError exposes the Decision to callers that surface
required actions to the investor.

Audit is delegated through AuditSink; canonical AuditLogSink bridges
to broker/pkg/audit. PII (ClientIP) is masked to /24 / /48 in the
audit row.

Test coverage: happy path, every hard-deny predicate (kyc_not_verified,
kyc_stale, not_accredited, accreditation_stale_{income,third_party},
suitability_{missing,stale}, aml_not_clean, open_sar,
ofac_screen_stale, lockup_active, rule144_holding_period,
country_restricted, max_holders, transfer_restricted,
chain_compliance_refused), every escalate predicate
(suitability_ambiguous, kyc_near_expiry, accreditation_near_expiry),
upstream-provider error propagation, input validation, max-holder
sell-side exemption, max-holder existing-holder exemption, PII
redaction. 41 tests passing under -race.

Source-of-design: Lux-Prior-IP
Source-ref: f7a8d7a
Clean-Team-Member: dev-agent (P0 G-37 implementation)
Signed-off-by: dev <[email protected]>
2026-05-26 18:11:50 -07:00

97 lines
3.0 KiB
Go

package pretrade
import (
"context"
"errors"
"fmt"
)
// OrderEnricher maps a raw broker-side order (whatever shape the caller
// uses) into the pretrade.Order shape. Wiring into broker/pkg/router is
// done by the consumer because the router does not know about the
// investor / offering binding — that lives in the cap-table.
type OrderEnricher func(ctx context.Context, accountID string, raw any) (*Order, error)
// SubmitFunc is the underlying order-placement function the broker
// already exposes (e.g., Router.SmartOrder). The middleware wraps it.
type SubmitFunc[Req any, Resp any] func(ctx context.Context, accountID string, req Req) (Resp, error)
// GateError is returned when the pre-trade gate denies or escalates an
// order. It carries the full Decision so callers can surface the
// reasons + RequiredActions to the user.
type GateError struct {
Decision *Decision
}
// Error satisfies the error interface.
func (e *GateError) Error() string {
if e == nil || e.Decision == nil {
return "pretrade: gate denied"
}
if e.Decision.Deny {
return fmt.Sprintf("pretrade: denied — %s", joinCodes(e.Decision.Reasons))
}
if e.Decision.Escalate {
return fmt.Sprintf("pretrade: escalate — %s", joinCodes(e.Decision.Reasons))
}
return "pretrade: unknown gate outcome"
}
// IsDeny reports whether the underlying decision is a hard deny.
func (e *GateError) IsDeny() bool { return e != nil && e.Decision != nil && e.Decision.Deny }
// IsEscalate reports whether the underlying decision is an escalation.
func (e *GateError) IsEscalate() bool {
return e != nil && e.Decision != nil && e.Decision.Escalate
}
// IsGateError unwraps an error that may wrap a GateError.
func IsGateError(err error) (*GateError, bool) {
var ge *GateError
if errors.As(err, &ge) {
return ge, true
}
return nil, false
}
// Wrap returns a SubmitFunc that runs the pretrade gate before delegating
// to inner. If the gate denies or escalates, inner is not called and a
// *GateError is returned. If the gate's upstream provider errors, the
// upstream error is propagated and inner is not called.
//
// Generics keep the wrapper type-safe over whatever concrete order /
// response shapes the caller uses, without locking pretrade into the
// broker/pkg/types surface.
func Wrap[Req any, Resp any](g *Gate, enricher OrderEnricher, inner SubmitFunc[Req, Resp]) SubmitFunc[Req, Resp] {
return func(ctx context.Context, accountID string, req Req) (Resp, error) {
var zero Resp
o, err := enricher(ctx, accountID, req)
if err != nil {
return zero, fmt.Errorf("pretrade enrich: %w", err)
}
dec, err := g.Check(ctx, o)
if err != nil {
return zero, fmt.Errorf("pretrade check: %w", err)
}
if !dec.Allow {
return zero, &GateError{Decision: dec}
}
return inner(ctx, accountID, req)
}
}
func joinCodes(rs []Reason) string {
parts := make([]string, 0, len(rs))
for _, r := range rs {
parts = append(parts, r.Code)
}
out := ""
for i, p := range parts {
if i > 0 {
out += ", "
}
out += p
}
return out
}