Skip to content

RFC: Layered Plugin Enforcement (Global Floor + Project Layer)

Status: Implemented 2026-07-20 on feat/layered-plugin-enforcement (all six workstreams; migration 0041) Branch context: builds on feat/plugin-configurations (plugin snapshots → plugin configurations rename, environments removed) Companion: docs/rfcs/rfc-hook-plugin-system.md, docs/rfcs/rfc-runtime-project-decision-path.md, llm-gateway/docs/verification.md, llm-gateway/docs/plugin-configuration-as-code.md

Today the gateway pins exactly one plugin configuration per request:

  • Project traffic pins the project-scoped configuration. A project with no active configuration is denied (configuration_missing). The project configuration entirely replaces the unscoped one.
  • Projectless traffic pins the unscoped “default” configuration.
  • The only cross-scope behavior is the control-plane read-path fallback (AdapterProjectConfigurationDefaultFallbackEnabled), which never affects the gateway hot path.

Organizations want a single org-wide enforcement configuration guaranteed to run on all agent traffic — both LLM (/v1/... provider routes) and relay (/v1/relay) — with project configurations layered additively on top. A project must never be able to weaken, disable, or reorder the org floor.

Deployment topology is one org per gateway deployment, so the existing unscoped (project_urn IS NULL) configuration becomes the org’s global enforcement configuration. No new scope key or schema tier. This is an unconditional behavior change shipped on an already-breaking (feat!) release train.

  1. Global floor, then project — additive only. Per hook stage, global entries run first (their internal order_index order preserved), then project entries. Same plugin_id in both layers → both instances run, each with its own config.
  2. Global-then-project for every hook (request.start, project.bootstrap, proxy.pre, proxy.post, request.end). An enforcement-mode deny short-circuits the entire remaining combined chain (both layers). Context writes accumulate across the combined chain, so global entries’ writes are visible to project entries.
  3. Project with no active configuration → allowed under global-only. The configuration_missing hard deny is removed. Projectless traffic remains global-only. Relay and LLM behave identically (they already share the pin + hook pipeline).
  4. Membership is config-driven in either layer. Global and/or project configurations MAY carry builtin.project_membership entries at project.bootstrap. At project-bind time, if the effective (global+project) chain has no enabled membership entry, the request is denied with new reason project_membership_plugin_missing. The old invariant “project config must contain exactly one membership entry” relaxes to “any number per layer; ≥1 across layers, enforced at runtime”.
  5. Audit records both layer identities — global configuration id+version and project configuration id+version, either nullable. Per-entry hook events record which layer produced them. Each layer keeps its own signed manifest; there is no composite signing.
  6. Terminology: defaultglobal across API, code, UI, and CLI. The control-plane fallback flag AdapterProjectConfigurationDefaultFallbackEnabled is retired (subsumed by layering).
  7. Scope of work: gateway runtime + shared/servicekit + control-plane layered resolution API + console (layered project resolution view and a new read-only global configuration page) + guardian-plugin CLI + DB migration.

3. Verified constraints and gotchas that shape the design

Section titled “3. Verified constraints and gotchas that shape the design”
  • A — async request.end queue is single-identity. endWorkItem (llm-gateway/service/internal/hooks/end_queue.go:30) persists one (ConfigurationID, Version) to an on-disk NDJSON queue, and NextEntryIndex resumes a partially-failed chain mid-way. The item must carry both layer identities, and the resume index must be defined over the combined global+project entry chain. Queue format change ⇒ drain queues at deploy (release-note; migration 0040 set the precedent).
  • B — request-lifetime read lock. PinDefaultConfiguration (hooks/scoped_runtime.go:82) holds activeMu.RLock() until request completion. Once the global pin is universal (every request, including long-lived SSE streams), a pending activation swap (writer) would block all new requests behind the longest in-flight stream (Go RWMutex writer preference). Must move to a refcounted active-config holder.
  • C — bootstrap validation is enforced in three places: gateway compile (hooks/system.go:711), control-plane activation (project_configuration_handlers.go:783), and the validate endpoint (:726). All move to the relaxed validator. Also: nothing today prevents a global configuration from containing project.bootstrap entries — they were simply never executed; after this change they will execute. Release-note.
  • D — single-identity plumbing. ExecutionResult and hookState merge/audit (gatewayhttp/server.go:3002–3037, setConfigurationPin) carry one configuration id/version that drives both audit and request.end pinning — needs a per-layer rework, not just added fields.
  • E — discovery reads audit scope columns. control-plane/service/internal/discovery/types.go:305–307 and project_store.go:144/331/442 read plugin_configuration_id/scope/fallback_used for project agent runtime views — must move to the dual columns.
  • F — relay proxy.post gate. relay.go:152–158 gates proxy.post via hookState.hasPinnedHook/HasActiveHook — needs the layered HasHook.
  • G — performance budget. gatewayhttp/hook_chain_perf_test.go and overhead_check_test.go need re-baselining. The hot path must stay at ≤1 DB round-trip for project traffic and 0 for projectless traffic.

4. Workstream 1 — servicekit contract/store + migration 0041

Section titled “4. Workstream 1 — servicekit contract/store + migration 0041”
  • ConfigurationResolutionScopeDefaultConfigurationResolutionScopeGlobal ("global"); doc sweep default→global.

  • Delete ConfigurationResolutionOptions.DefaultFallbackEnabled.

  • Replace ConfigurationResolution with:

    type EffectiveConfigurationResolution struct {
    State ConfigurationResolutionState
    ProjectURN string
    Global, Project *ActiveConfiguration // either nullable
    MembershipSatisfied bool
    RollbackCandidates []ActiveConfiguration // project scope
    }
  • Store interfaces: ResolveCurrentConfigurationResolveEffectiveConfiguration (both layers read in ONE repeatable-read read-only tx for snapshot consistency); RuntimeScopedConfigurationStore gains GetCurrentConfigurationIdentitiesForLayers(ctx, projectURN) — a single query WHERE is_current AND (project_urn IS NULL OR project_urn = $1) returning ≤2 rows.

  • HookEventRecord/AsyncEventRecord gain Layer + ConfigurationID; EndDLQRecord gains Layer.

  • New helper HasEnabledMembershipBootstrapEntry(entries) shared by the gateway bind precheck and control-plane membership_satisfied.

  • ValidateProjectConfigurationBootstrap (:346) → ValidateConfigurationBootstrapEntries, applied to EVERY configuration (global too): keep all per-entry shape rules (premade runtime, enforcement mode, no capabilities, membership-only at project.bootstrap); drop the exactly-one count rule. Update the three callers (gotcha C).

Migration llm-gateway/service/migrations/0041_layered_plugin_enforcement.up.sql

Section titled “Migration llm-gateway/service/migrations/0041_layered_plugin_enforcement.up.sql”
  • gateway_audit_exchanges: add nullable plugin_configuration_global_id/_version + plugin_configuration_project_id/_version; backfill from the old columns keyed on recorded scope ('default'/'' → global pair, 'project' → project pair); drop plugin_configuration_id/_version/_scope/_fallback_used. Scope becomes structural (which pair is set); fallback is a retired concept; the default→global audit value rename becomes moot.
  • gateway_plugin_hook_events / gateway_plugin_async_events / gateway_plugin_end_dlq: add configuration_layer TEXT NOT NULL DEFAULT 'global' (+ configuration_id BIGINT on hook/async tables to disambiguate equal versions across scopes).
  • Activation/configuration tables: no change — the 0040 partial unique indexes already model one current global + one current per project.
  • .down.sql: best-effort reconstruction of the old columns from the pairs.

shared/servicekit/plugins/postgres_store.go

Section titled “shared/servicekit/plugins/postgres_store.go”

Implement the two new methods; write the new event columns in the three writers. trust.go untouched (per-layer signed manifests, no composite signing).

5. Workstream 2 — hooks runtime (riskiest)

Section titled “5. Workstream 2 — hooks runtime (riskiest)”

Composition mechanism: a LayeredPin holding [globalPin?, projectPin?], executed per hook by iterating layers. Do NOT merge into one compiled set. Merging would break per-configuration signed identity, compiled-cache keying (configurationCacheKey{id,version}), and compiledConfiguration.close() ownership. Iterating two compiled configs reuses both existing caches unchanged, keeps invalidation per-layer, and both pins are snapshotted at acquisition, so mid-flight activation of either layer never affects an in-flight request (same guarantee as today).

llm-gateway/service/internal/hooks/scoped_runtime.go

Section titled “llm-gateway/service/internal/hooks/scoped_runtime.go”
type LayeredPin struct {
Global *ConfigurationPin // nil = no active global configuration
Project *ConfigurationPin // nil = projectless traffic or no project configuration
}
// Release() (both, idempotent), HasHook(h) (either layer),
// GlobalIdentity()/ProjectIdentity(), HasEnabledMembershipEntry()
func (s *System) PinLayeredConfiguration(ctx context.Context, projectURN string) (*LayeredPin, error)
func (s *System) AttachProjectLayer(ctx context.Context, pin *LayeredPin, projectURN string) error
  • Global refcount rework (gotcha B): wrap System.active in a refcounted holder; swapActiveConfiguration swaps the pointer and defers close() until refs drain (same discipline as the project LRU cache). Removes the request-lifetime RLock.
  • Project layer: existing pinCompiledProjectConfiguration path, identity resolved via GetCurrentConfigurationIdentitiesForLayers — project traffic stays at ONE store round-trip; projectless traffic needs zero DB calls (the global compiled config is in memory).
  • Drop ConfigurationPin.ResolutionScope/FallbackUsed (scope is now structural).

Refactor executeCompiledConfiguration (:365) into executeLayers + ExecuteLayered(ctx, pin, hook, input): one loop over global entries then project entries; single accumulated currentContext (context flows global→project for free); enforcement deny breaks the combined loop. InvocationSummary and event records gain Layer; ExecutionResult replaces ConfigurationID/Version with Global*/Project* identity pairs; HasRequestEndHooks = either layer.

Budget: combined across the effective chain

Section titled “Budget: combined across the effective chain”

Extend hookExecutionBudget (:1157) to sum matched+enabled entry timeouts across both layers, once per hook; perEntryTimeout clamps to the shared deadline as today. Rationale: (a) preserves the invariant that a hook’s wall-clock bound equals the sum of the timeouts of the entries that will actually run; (b) a fixed shared cap would starve project entries — they always run last — turning a global-floor change into spurious enforcement errors→denies. MaxEntriesPerHook=64 stays a per-layer create-time limit (effective chain up to 128 — document); no cross-layer create-time cap, so a later global change can never invalidate existing project configurations.

Bootstrap generalization (ExecuteProjectBootstrap, scoped_runtime.go:298)

Section titled “Bootstrap generalization (ExecuteProjectBootstrap, scoped_runtime.go:298)”

Collect enabled builtin.project_membership entries at project.bootstrap from the global then project layer; drop the duplicate-entry error; execute each in chain order (own typed call, timeout, and layer-labeled HookEventRecord); all must allow — first deny/error short-circuits. Project context is established by the LAST allowing decision with EstablishContext=true (most-specific layer wins; global-only chains can still establish).

endWorkItem carries both nullable identity pairs; NextEntryIndex indexes the combined chain, global entries first — document the invariant on the struct. executeRequestEndItem (:355) resolves each present layer (active-match → project LRU/DB → loadPersistedConfigurationByID, already identity-keyed) and executes request.end entries global-then-project. EndDLQRecord gains Layer. Keep the live-config fallback branch (system.go:599) defensively.

  • hookState: layeredPin *hooks.LayeredPin; drop configurationScope/configurationFallback; per-layer id/version fields set once at pin time; merge auditMetadata + projectConfigurationAuditMetadata into one pluginAuditMetadata() returning both identity pairs + deniedBy/decision/bootstrap fields (gotcha D).
  • request.start moves onto the pinned global layer: create the LayeredPin (global layer) at hookState creation (~:1567) and run request.start via ExecuteLayered. This puts request.start in the same per-request global snapshot as every later hook (fixes a latent version-skew today where it uses the live config via ExecuteSync while later hooks use the pin).
  • bindAndBootstrapProject (~:1710) rewrite:
    1. Binding rejected → clear pin, deny (unchanged).
    2. No project candidate → keep the global-only pin (replaces the PinDefaultConfiguration call).
    3. Candidate → AttachProjectLayer; store error → deny ReasonConfigurationError (unchanged); project configuration missing → proceed global-only (deny removed).
    4. Membership gate: !pin.HasEnabledMembershipEntry() → clear pin, deny with new projectctx.ReasonMembershipPluginMissing = "project_membership_plugin_missing" (projectctx.go; ReasonConfigurationMissing retired).
    5. ExecuteProjectBootstrap (generalized); rest unchanged.
  • executeHookForState (~:1849) dispatches to ExecuteLayered; the project layer is executable only after bootstrap (via projectPluginsEnabled, as today).
  • relay.go:152–158: gate proxy.post on the layered HasHook (gotcha F). No other relay change — parity falls out of the shared pipeline.
  • Audit writer: dual identity columns.

Deploy coupling: WS1’s migration + WS2/WS3 gateway changes ship as one coordinated deploy; drain the end-queue and audit queues first.

  • Retire AdapterProjectConfigurationDefaultFallbackEnabled everywhere: config/runtime.go:133,209,324,531,649,922, cmd/guardian-control-plane/main.go:261, controlhttp/server.go:168,228,375 (flag, TOML key project_configuration_default_fallback_enabled, env var).
  • project_configuration_handlers.go:
    • handleGetCurrentProjectPluginConfiguration (:259) → ResolveEffectiveConfiguration; new response shape:

      {
      "state": "...",
      "project_uuid": "...", "project_urn": "...",
      "global": { "...configuration summary..." },
      "project": { "...configuration summary..." },
      "membership_satisfied": true,
      "effective_hooks": { "proxy.pre": [ { "layer": "global", "...entry..." } ] },
      "rollback_candidates": [ ... ]
      }

      effective_hooks is computed server-side with the exact gateway ordering (global entries in (hook, order_index, plugin_definition_id, entry_id) order, then project; enabled only) so console and gateway agree.

    • Activate/rollback handlers return the same layered shape (replace projectConfigurationResolutionFromActive).

    • validateProjectConfigurationEntries (:726): relaxed validator; membership absence is no longer an error — emit a non-blocking warnings entry when the combined chain (given the current global) lacks membership.

  • plugin_handlers.go: global /current behaviorally unchanged (the console global page consumes it; requirePrincipal auth is sufficient); doc sweep; regenerate swagger.
  • discovery package: dual audit columns (gotcha E).
  • types/gateway-adapter.ts: GatewayEffectiveConfigurationResolution (global/project/membership_satisfied/effective_hooks); scope union → "project" | "global" | "none"; add gatewayGlobalConfigurationPath (/v1/plugin-configurations/current).
  • services/projects.ts update + new global-config service; hooks/use-project-configuration.ts retype; new hooks/use-global-configuration.ts.
  • components/projects/project-configuration-view.tsx: ResolutionBanner states → project+global / global-only (“allowed under global floor”) / none, with a membership-missing warning when membership_satisfied === false; two layer sections (global floor read-only; project layer keeps lifecycle/rollback); new per-hook effective-chain panel with layer badges.
  • New pages/GlobalConfiguration.tsx (read-only, mirrors the project page) + route in App.tsx + nav entry. Management stays in the CLI (“Managed as code”).
  • services/gateway-adapter-fixtures.ts: replace the active/fallback/empty resolution fixtures with layered fixtures including a membership-unsatisfied case; mock mode (VITE_API_MODE) preserved.
  • Update tests/project-configurations.spec.ts; add a global-page spec.
  • crates/guardian-plugin/src/manifest.rs: ConfigScopeKind::DefaultGlobal (serde ⇒ scope = "global"). Hard rename, no "default" alias, with a parse-error hint (scope "default" was renamed to "global").
  • crates/guardian-plugin/src/configuration.rs: Scope::Global, output strings (“global configuration”); decode the layered /current shape for resolution display.
  • config show --effective: renders the combined per-hook chain the Gateway executes (layer identities, membership_satisfied, entries with layer labels in lifecycle order) from effective_hooks; global scope renders the floor alone.
  • Sweep docs/examples/configurations/default/global/; docs prose; the guardian-plugin CI workflow if it names the scope value.
  • Release notes (breaking): resolution API shape, control-plane config key removal, end-queue format (drain required), audit column change, scope value rename, global project.bootstrap entries now execute, project-without-config now allowed under the global floor.
  • servicekit: layers-identity query (0/1/2 rows); effective-resolution snapshot consistency; relaxed bootstrap validation (0/1/N membership entries pass; shape violations still fail); migration 0041 up/down against seeded 0040 data.
  • hooks: layer ordering per hook; duplicate plugin_id runs twice; a global deny skips the remaining global AND all project entries (and the project-deny inverse); context writes global→project; combined-budget exhaustion; refcounted global swap under load (no close while pinned); request.end replay with both layers including NextEntryIndex resume across the layer boundary; bootstrap membership in global-only / project-only / both / neither.
  • gatewayhttp integration: project-without-config → allowed global-only; membership-missing → deny project_membership_plugin_missing; LLM + relay parity for pre/post; audit rows carry both identities; request.start snapshot equals later-hook snapshot under concurrent activation; re-baseline hook_chain_perf_test.go / overhead_check_test.go.
  • control-plane: layered /current shape + effective_hooks ordering matches the gateway; create/validate without membership succeeds with a warning; fallback key gone from the config surface.
  • console: vitest for new hooks/components; playwright for the project layered view + global page in mock mode.
  • e2e smoke (per llm-gateway/docs/local-plugin-e2e.md): activate a global config, send project traffic with and without a project config through both LLM and /v1/relay paths; confirm execution order + audit rows.

Contract note: request.start is global-only

Section titled “Contract note: request.start is global-only”

request.start executes before verification and therefore before the project layer can be attached; project-scoped request.start entries never run. They are excluded from the control-plane effective_hooks contract and the validation endpoint flags them with a request_start_never_executes warning. Membership entries belong at project.bootstrap; org-wide request.start policy belongs in the global configuration.

  • Execution semantics: the global (unscoped) configuration now runs as an enforcement floor on EVERY request — LLM and relay — with project entries additive after it. Previously a project configuration fully replaced the global one. Global configurations containing project.bootstrap membership entries were previously never executed; they now execute for all project-bound traffic.
  • Project traffic without a project configuration is now allowed under the global floor (previously denied with project_configuration_missing). The new deny is project_membership_plugin_missing, raised when the effective chain carries no enabled membership bootstrap entry.
  • Membership invariant relaxed: project configurations no longer require exactly one builtin.project_membership entry; either layer may carry any number (shape rules unchanged), with ≥1 across layers required at bind time.
  • Audit schema (migration 0041): gateway_audit_exchanges.plugin_configuration_id/_version/_scope/_fallback_used replaced by plugin_configuration_global_id/_version + plugin_configuration_project_id/_version. Hook/async/DLQ event tables gain configuration_layer (+ configuration_id). Drain audit queues before deploying.
  • request.end queue format changed (dual layer identities + combined-chain resume index). Drain plugin-request-end-queue.ndjson before deploying the new gateway.
  • Control-plane API: project /plugin-configurations/current (and activate/rollback responses) now return the layered shape {state, global, project, membership_satisfied, effective_hooks, rollback_candidates}; resolution_scope/fallback_used/active_configuration are gone. The validate endpoint gains non-blocking warnings.
  • Config surface: CONTROL_PLANE_ADAPTER_AUTH_PROJECT_CONFIGURATION_DEFAULT_FALLBACK_ENABLED / project_configuration_default_fallback_enabled / -adapter-auth-project-configuration-default-fallback-enabled removed (fallback is subsumed by layering).
  • CLI: guardian.config.toml scope value "default" renamed to "global" (hard rename; the parser emits a rename hint). JSON output scope.kind and validation_status values renamed accordingly (skipped_for_global).
  • Docs/examples: docs/examples/configurations/default/ moved to global/.
#WorkstreamNotes
1WS1 servicekit + migration 0041Base of the stack
2WS2 hooks runtimeRiskiest: hot-path pin/refcount rework + end-queue format
3WS3 gatewayhttpCoordinated deploy with WS1’s migration; drain queues
4WS4 control-planeLayered /current, retire fallback key, discovery columns, swagger
5WS5 consoleLayered view + global page
6WS6 CLI + docsRename + release notes