Sentinel

Forge Extension

Mount Sentinel into a Forge application as a first-class extension with automatic route registration and migrations.

Sentinel ships a ready-made Forge extension in the extension package. It wires the engine, HTTP API, and lifecycle management into Forge's extension system.

Installation

import "github.com/xraph/sentinel/extension"

Registering the extension

package main

import (
    "github.com/xraph/forge"
    "github.com/xraph/sentinel/extension"
)

func main() {
    app := forge.New()

    sentinelExt := extension.New(
        extension.WithGroveDatabase(""), // resolve default grove.DB from DI
    )

    app.RegisterExtension(sentinelExt)
    app.Run()
}

Or with an explicit store:

sentinelExt := extension.New(
    extension.WithStore(pgstore.New(db)),
)

What the extension does

Lifecycle eventBehaviour
RegisterCreates the engine from the provided options
StartRuns store.Migrate (unless disabled)
RegisterRoutesMounts all Sentinel HTTP endpoints under the base path
StopCalls engine.Stop -- emits OnShutdown to all plugins

Extension options

OptionTypeDefaultDescription
WithStore(s)store.Store--Composite store (auto-resolved from grove if not set)
WithExtension(x)plugin.Extension--Lifecycle hook plugin (repeatable)
WithEngineOption(opt)engine.Option--Pass engine option directly
WithConfig(cfg)ConfigdefaultsFull config struct
WithDisableRoutes()--falseSkip HTTP route registration
WithDisableMigrate()--falseSkip migrations on Start
WithBasePath(path)string""URL prefix for all sentinel routes
WithGroveDatabase(name)string""Name of the grove.DB to resolve from DI
WithRequireConfig(b)boolfalseRequire config in YAML files

File-based configuration (YAML)

When running in a Forge application, the Sentinel extension automatically loads configuration from YAML config files. The extension looks for config under the following keys (in order):

  1. extensions.sentinel -- standard Forge extension config namespace
  2. sentinel -- top-level shorthand

Example YAML config

# forge.yaml
extensions:
  sentinel:
    disable_routes: false
    disable_migrate: false
    base_path: "/sentinel"
    default_model: "smart"
    temperature: 0
    pass_threshold: 0.7
    concurrency: 4
    shutdown_timeout: "30s"
    grove_database: ""

Or using the top-level shorthand:

# forge.yaml
sentinel:
  default_model: "gpt-4o"
  pass_threshold: 0.8
  concurrency: 8

Config fields

YAML KeyTypeDefaultDescription
disable_routesboolfalseSkip HTTP route registration
disable_migrateboolfalseSkip migrations on Start
base_pathstring""URL prefix for all routes
default_modelstring"smart"LLM model identifier
temperaturefloat640LLM sampling temperature
pass_thresholdfloat640.7Minimum score to pass a test case
concurrencyint4Parallel evaluation workers
shutdown_timeoutduration"30s"Max graceful shutdown wait time
grove_databasestring""Named grove.DB to resolve from DI

Merge behaviour

File-based configuration is merged with programmatic options. Programmatic boolean flags (DisableRoutes, DisableMigrate) always win when set to true. For other fields, YAML values take precedence, then programmatic values, then defaults.

Requiring configuration

If your deployment requires YAML config to be present, use WithRequireConfig:

ext := extension.New(
    extension.WithRequireConfig(true), // error if no YAML config found
)

Accessing the engine from other extensions

After Register is called by Forge, sentinelExt.Engine() returns the fully initialised engine:

eng := sentinelExt.Engine()
// use eng.CreateSuite, eng.ListRuns, etc. from another extension

Tenant middleware

In a Forge app, tenant scope is typically set by middleware. Implement a Forge middleware that calls sentinel.WithTenant and sentinel.WithApp:

func tenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenantID := r.Header.Get("X-Tenant-ID")
        appID := r.Header.Get("X-App-ID")
        ctx := sentinel.WithTenant(r.Context(), tenantID)
        ctx = sentinel.WithApp(ctx, appID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

router.Use(tenantMiddleware)

Grove database integration

When your Forge app uses the Grove extension to manage database connections, Sentinel can automatically resolve a grove.DB from the DI container and construct the correct store backend based on the driver type.

Using the default grove database

If the Grove extension registers a single database (or a default in multi-DB mode), use WithGroveDatabase with an empty name:

ext := extension.New(
    extension.WithGroveDatabase(""),
)

Using a named grove database

In multi-database setups, reference a specific database by name:

ext := extension.New(
    extension.WithGroveDatabase("sentinel"),
)

This resolves the grove.DB named "sentinel" from the DI container and auto-constructs the matching store. The driver type is detected automatically -- you do not need to import individual store packages.

Store resolution order

The extension resolves its store in this order:

  1. Explicit store -- if WithStore(s) was called, it is used directly and grove is ignored.
  2. Grove database -- if WithGroveDatabase(name) was called, the named or default grove.DB is resolved from DI.
  3. In-memory fallback -- if neither is configured, an in-memory store is used.

Adding metrics

Register the observability extension alongside the Sentinel engine extension:

import "github.com/xraph/sentinel/observability"

metricsExt := observability.NewMetricsExtensionWithFactory(fapp.Metrics())

sentinelExt := extension.New(
    extension.WithStore(store),
    extension.WithExtension(metricsExt),
)

On this page