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
1. Problem & goal
Section titled “1. Problem & goal”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.
2. Decided semantics
Section titled “2. Decided semantics”- Global floor, then project — additive only. Per hook stage, global entries run first (their internal
order_indexorder preserved), then project entries. Sameplugin_idin both layers → both instances run, each with its own config. - 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. - Project with no active configuration → allowed under global-only. The
configuration_missinghard deny is removed. Projectless traffic remains global-only. Relay and LLM behave identically (they already share the pin + hook pipeline). - Membership is config-driven in either layer. Global and/or project configurations MAY carry
builtin.project_membershipentries atproject.bootstrap. At project-bind time, if the effective (global+project) chain has no enabled membership entry, the request is denied with new reasonproject_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”. - 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.
- Terminology:
default→globalacross API, code, UI, and CLI. The control-plane fallback flagAdapterProjectConfigurationDefaultFallbackEnabledis retired (subsumed by layering). - 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-pluginCLI + 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.endqueue is single-identity.endWorkItem(llm-gateway/service/internal/hooks/end_queue.go:30) persists one(ConfigurationID, Version)to an on-disk NDJSON queue, andNextEntryIndexresumes 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) holdsactiveMu.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 containingproject.bootstrapentries — they were simply never executed; after this change they will execute. Release-note. - D — single-identity plumbing.
ExecutionResultandhookStatemerge/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–307andproject_store.go:144/331/442readplugin_configuration_id/scope/fallback_usedfor project agent runtime views — must move to the dual columns. - F — relay proxy.post gate.
relay.go:152–158gates proxy.post viahookState.hasPinnedHook/HasActiveHook— needs the layeredHasHook. - G — performance budget.
gatewayhttp/hook_chain_perf_test.goandoverhead_check_test.goneed 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”shared/servicekit/plugins/contract.go
Section titled “shared/servicekit/plugins/contract.go”-
ConfigurationResolutionScopeDefault→ConfigurationResolutionScopeGlobal("global"); doc sweep default→global. -
Delete
ConfigurationResolutionOptions.DefaultFallbackEnabled. -
Replace
ConfigurationResolutionwith:type EffectiveConfigurationResolution struct {State ConfigurationResolutionStateProjectURN stringGlobal, Project *ActiveConfiguration // either nullableMembershipSatisfied boolRollbackCandidates []ActiveConfiguration // project scope} -
Store interfaces:
ResolveCurrentConfiguration→ResolveEffectiveConfiguration(both layers read in ONE repeatable-read read-only tx for snapshot consistency);RuntimeScopedConfigurationStoregainsGetCurrentConfigurationIdentitiesForLayers(ctx, projectURN)— a single queryWHERE is_current AND (project_urn IS NULL OR project_urn = $1)returning ≤2 rows. -
HookEventRecord/AsyncEventRecordgainLayer+ConfigurationID;EndDLQRecordgainsLayer. -
New helper
HasEnabledMembershipBootstrapEntry(entries)shared by the gateway bind precheck and control-planemembership_satisfied.
shared/servicekit/plugins/validation.go
Section titled “shared/servicekit/plugins/validation.go”ValidateProjectConfigurationBootstrap(:346) →ValidateConfigurationBootstrapEntries, applied to EVERY configuration (global too): keep all per-entry shape rules (premade runtime, enforcement mode, no capabilities, membership-only atproject.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 nullableplugin_configuration_global_id/_version+plugin_configuration_project_id/_version; backfill from the old columns keyed on recorded scope ('default'/''→ global pair,'project'→ project pair); dropplugin_configuration_id/_version/_scope/_fallback_used. Scope becomes structural (which pair is set); fallback is a retired concept; thedefault→globalaudit value rename becomes moot.gateway_plugin_hook_events/gateway_plugin_async_events/gateway_plugin_end_dlq: addconfiguration_layer TEXT NOT NULL DEFAULT 'global'(+configuration_id BIGINTon 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.activein a refcounted holder;swapActiveConfigurationswaps the pointer and defersclose()until refs drain (same discipline as the project LRU cache). Removes the request-lifetime RLock. - Project layer: existing
pinCompiledProjectConfigurationpath, identity resolved viaGetCurrentConfigurationIdentitiesForLayers— 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).
Execution (system.go)
Section titled “Execution (system.go)”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).
request.end queue (end_queue.go)
Section titled “request.end queue (end_queue.go)”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.
6. Workstream 3 — gatewayhttp
Section titled “6. Workstream 3 — gatewayhttp”server.go
Section titled “server.go”hookState:layeredPin *hooks.LayeredPin; dropconfigurationScope/configurationFallback; per-layer id/version fields set once at pin time; mergeauditMetadata+projectConfigurationAuditMetadatainto onepluginAuditMetadata()returning both identity pairs + deniedBy/decision/bootstrap fields (gotcha D).request.startmoves onto the pinned global layer: create theLayeredPin(global layer) at hookState creation (~:1567) and run request.start viaExecuteLayered. 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 viaExecuteSyncwhile later hooks use the pin).bindAndBootstrapProject(~:1710) rewrite:- Binding rejected → clear pin, deny (unchanged).
- No project candidate → keep the global-only pin (replaces the
PinDefaultConfigurationcall). - Candidate →
AttachProjectLayer; store error → denyReasonConfigurationError(unchanged); project configuration missing → proceed global-only (deny removed). - Membership gate:
!pin.HasEnabledMembershipEntry()→ clear pin, deny with newprojectctx.ReasonMembershipPluginMissing = "project_membership_plugin_missing"(projectctx.go;ReasonConfigurationMissingretired). ExecuteProjectBootstrap(generalized); rest unchanged.
executeHookForState(~:1849) dispatches toExecuteLayered; the project layer is executable only after bootstrap (viaprojectPluginsEnabled, as today).relay.go:152–158: gate proxy.post on the layeredHasHook(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.
7. Workstream 4 — control-plane
Section titled “7. Workstream 4 — control-plane”- Retire
AdapterProjectConfigurationDefaultFallbackEnabledeverywhere:config/runtime.go:133,209,324,531,649,922,cmd/guardian-control-plane/main.go:261,controlhttp/server.go:168,228,375(flag, TOML keyproject_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_hooksis 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-blockingwarningsentry when the combined chain (given the current global) lacks membership.
-
plugin_handlers.go: global/currentbehaviorally unchanged (the console global page consumes it;requirePrincipalauth is sufficient); doc sweep; regenerate swagger.discoverypackage: dual audit columns (gotcha E).
8. Workstream 5 — console
Section titled “8. Workstream 5 — console”types/gateway-adapter.ts:GatewayEffectiveConfigurationResolution(global/project/membership_satisfied/effective_hooks); scope union →"project" | "global" | "none"; addgatewayGlobalConfigurationPath(/v1/plugin-configurations/current).services/projects.tsupdate + new global-config service;hooks/use-project-configuration.tsretype; newhooks/use-global-configuration.ts.components/projects/project-configuration-view.tsx:ResolutionBannerstates → project+global / global-only (“allowed under global floor”) / none, with a membership-missing warning whenmembership_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 inApp.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.
9. Workstream 6 — CLI + docs
Section titled “9. Workstream 6 — CLI + docs”crates/guardian-plugin/src/manifest.rs:ConfigScopeKind::Default→Global(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/currentshape 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) fromeffective_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.bootstrapentries now execute, project-without-config now allowed under the global floor.
10. Verification
Section titled “10. Verification”- 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_idruns 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 includingNextEntryIndexresume 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-baselinehook_chain_perf_test.go/overhead_check_test.go. - control-plane: layered
/currentshape +effective_hooksordering 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/relaypaths; 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.
11. Release notes (breaking)
Section titled “11. Release notes (breaking)”- 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.bootstrapmembership 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 isproject_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_membershipentry; 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_usedreplaced byplugin_configuration_global_id/_version+plugin_configuration_project_id/_version. Hook/async/DLQ event tables gainconfiguration_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.ndjsonbefore 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_configurationare gone. The validate endpoint gains non-blockingwarnings. - Config surface:
CONTROL_PLANE_ADAPTER_AUTH_PROJECT_CONFIGURATION_DEFAULT_FALLBACK_ENABLED/project_configuration_default_fallback_enabled/-adapter-auth-project-configuration-default-fallback-enabledremoved (fallback is subsumed by layering). - CLI:
guardian.config.tomlscope value"default"renamed to"global"(hard rename; the parser emits a rename hint). JSON outputscope.kindandvalidation_statusvalues renamed accordingly (skipped_for_global). - Docs/examples:
docs/examples/configurations/default/moved toglobal/.
12. Sequencing (PR-sized, in order)
Section titled “12. Sequencing (PR-sized, in order)”| # | Workstream | Notes |
|---|---|---|
| 1 | WS1 servicekit + migration 0041 | Base of the stack |
| 2 | WS2 hooks runtime | Riskiest: hot-path pin/refcount rework + end-queue format |
| 3 | WS3 gatewayhttp | Coordinated deploy with WS1’s migration; drain queues |
| 4 | WS4 control-plane | Layered /current, retire fallback key, discovery columns, swagger |
| 5 | WS5 console | Layered view + global page |
| 6 | WS6 CLI + docs | Rename + release notes |