Skip to content

feat(supervisor-middleware): add network egress middleware#2027

Open
pimlock wants to merge 28 commits into
mainfrom
1733-supervisor-middleware/pmlocek
Open

feat(supervisor-middleware): add network egress middleware#2027
pimlock wants to merge 28 commits into
mainfrom
1733-supervisor-middleware/pmlocek

Conversation

@pimlock

@pimlock pimlock commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the first usable RFC 0009 supervisor middleware slice: proto-backed, host-selected HTTP egress middleware for HttpRequest/pre_credentials, with both in-process built-ins and statically registered operator-run gRPC services.

The implementation covers RFC 0009 Phase 1 and adds basic external-service support from Phase 2. It establishes the contract, policy plumbing, ordered chain execution, built-in secret redaction, static gateway registration, relay integration, validation before policy persistence, body limits, audit events, and user-facing configuration and operations documentation.

Tip

See example middleware implementation: #2169

Related Issue

Closes #2010
Part of #1733
Design/RFC: #1738

Changes

  • Add the supervisor middleware contract and runtime, including the built-in openshell/secrets redactor and statically registered operator-run gRPC services.
  • Add ordered, host-selector-based network_middlewares policy configuration and validation, independent of the network policy rule that admits a request.
  • Enforce middleware on inspected HTTP requests before credential injection, with transformations, safe header additions, body limits, and explicit fail-open/fail-closed behavior.
  • Deliver effective service configuration to supervisors with resilient registry reload and last-known-good behavior.
  • Add OCSF observability plus gateway, policy, operations, architecture, and extensibility documentation.

Testing

  • mise run pre-commit passes
  • Unit and integration-style tests added or updated
  • E2E coverage considered
    • No separate gateway E2E was added. The request relay, policy validation, registry, and remote gRPC paths are covered by automated unit and integration-style tests.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated
  • Published docs updated

@copy-pr-bot

copy-pr-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@pimlock pimlock force-pushed the 1733-supervisor-middleware/pmlocek branch 2 times, most recently from 595191e to 97b750f Compare June 26, 2026 23:58
@pimlock pimlock self-assigned this Jun 30, 2026
@pimlock pimlock force-pushed the 1733-supervisor-middleware/pmlocek branch from 358906a to 1fbcdbc Compare June 30, 2026 19:48
@github-actions

Copy link
Copy Markdown

@pimlock

This comment was marked as outdated.

@pimlock

This comment was marked as outdated.

2 similar comments
@pimlock

This comment was marked as outdated.

@pimlock

This comment was marked as outdated.

@pimlock

pimlock commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test c4b0dcf

pimlock added 18 commits July 2, 2026 15:17
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
…are outages

An unreachable operator-registered middleware service previously aborted
sandbox startup via a hard error in load_policy, contradicting the
per-request on_error contract and the resilient live-reload path.

Retry the initial connect and, on failure, degrade to the built-in
registry so matched requests are governed by each config's on_error
(deny for fail_closed, allow for fail_open) instead of blocking the whole
sandbox. The policy poll loop now reconciles the registry on every poll
while an install is pending, so a recovered service is adopted without
waiting for a config change; a failed reconcile also no longer blocks
unrelated policy updates.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
…limit

A chain entry whose binding did not resolve reported a zero body limit,
which dragged the whole chain's buffer cap to zero and spuriously failed
body-bearing requests over capacity even when a resolved middleware could
have processed them. Exclude unresolved entries from the limit via a new
DescribedChainEntry::is_resolved(); when no entry resolves, skip buffering
and apply each entry's on_error directly.

Also fix two parallel-test flakes found while validating the change:

- Build middleware OCSF events into a Vec and assert on it directly
  instead of capturing through the global tracing pipeline, whose
  callsite-interest cache is process-global and raced under parallel runs.
- Accumulate the websocket deny response until the reason marker arrives
  rather than assuming a single read returns the full body.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock pimlock force-pushed the 1733-supervisor-middleware/pmlocek branch from c4b0dcf to 2b7cf4e Compare July 2, 2026 22:18
@pimlock pimlock requested review from a team and derekwaynecarr as code owners July 3, 2026 18:34
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock pimlock removed the test:e2e Requires end-to-end coverage label Jul 3, 2026
pimlock added 4 commits July 3, 2026 12:08
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock pimlock mentioned this pull request Jul 3, 2026
4 tasks
@pimlock pimlock added the test:e2e Requires end-to-end coverage label Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Label test:e2e applied for 3b58759. Open the existing run and click Re-run all jobs to execute with the label set. The run will execute the standard E2E suite after building the required gateway and supervisor images once. The matching required CI gate status on this PR will flip green automatically once the run finishes.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Plan startup and updates around these boundaries:

- Start registered services before the gateway. The gateway validates every registration during startup.
- Keep service endpoints reachable from both the gateway and sandbox supervisors. The supervisors call operator-run services directly on the request path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarifying question - What is the behavior when a service endpoint is unexpectedly or intermittently unavailable? This would be in the case where a on gateway and supervisor startups, the service was healthy, but then becomes unavailable later. What would happen?

@pimlock pimlock Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This depends on the on_error setting. If it's fail_open, the request will be sent upstream, if it's fail_closed (default), it will be rejected.

Comment thread proto/middleware.proto
Comment on lines +109 to +110
// HttpRequestTarget describes the admitted HTTP destination and request target.
message HttpRequestTarget {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again just out of curiosity - are generic headers in scope here or part of the target?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The headers are currently modelled as part of the request itself, this HttpRequestTarget is supposed to represent "where" and the rest (the "what") is in the HttpRequestEvaluation.

The reason for that split was for HttpRequestTarget to represent what the network policy operates on when making its allow/deny decision.

///
/// Matching is case-insensitive. Invalid or empty patterns return an error
/// instead of silently becoming a non-match.
pub fn host_matches(pattern: &str, host: &str) -> std::result::Result<bool, String> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Although we're using this to match a host, this is matching a general pattern to a string should we rename the function?

I'm also wondering if it make sense to introduce a Pattern type that wraps glob::Pattern and performs pattern validation on new?

use miette::{Result, miette};

/// Binding identifier for the built-in secret redaction middleware.
pub const BUILTIN_SECRETS: &str = "openshell/secrets";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this were go, I would consider implementing this as a type that implements some "Middleware" interface. Is something similar possible / idomatic in Rust?

Comment on lines +38 to +53
fn validate_secrets_config(config: &prost_types::Struct) -> Result<()> {
let mode = config
.fields
.get("secrets")
.and_then(|value| match value.kind.as_ref() {
Some(prost_types::value::Kind::StringValue(value)) => Some(value.as_str()),
_ => None,
})
.unwrap_or("redact");
if mode != "redact" {
return Err(miette!(
"{BUILTIN_SECRETS} only supports config.secrets: redact in phase 1"
));
}
Ok(())
}

@elezar elezar Jul 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not clear from this function which fields the openshell/secrets config actually has. Does this further justify a concrete type? (Something like what we do for the driver-config structs?)

Some(prost_types::value::Kind::StringValue(value)) => Some(value.as_str()),
_ => None,
})
.unwrap_or("redact");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the default value is redact even if no secrets field is present?


#[test]
fn secrets_config_defaults_to_redact() {
validate_builtin_config(BUILTIN_SECRETS, &prost_types::Struct::default()).unwrap();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't actually see redact mentioned in this test at all.

@drew drew left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback from agent review

Comment on lines +196 to +198
if endpoint.tls == "skip"
&& selector_matches_host(middleware, &endpoint.host).unwrap_or(false)
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Detect tls-skip conflicts against wildcard endpoints

When a tls: skip endpoint uses a wildcard host such as *.example.com and a middleware selector includes a concrete host such as api.example.com, this compares the selector against the literal endpoint pattern, so validation passes even though real traffic for api.example.com is admitted without TLS inspection and the required middleware cannot run. The conflict check needs to reason about selector/endpoint pattern overlap, not only whether the selector matches the endpoint string.

Comment on lines +1227 to +1235
let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?;
let req = match apply_middleware_chain(
req,
client,
ctx,
chain,
engine.middleware_runner(),
engine.generation_guard(),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like L7 policy is evaluated before middleware. So I'd imagine that the middleware could modify or replace an allowed operation with one that would otherwise be denied by policy. Is this intended? If so we should clearly document this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point.

As discussed - we want the policy to be guaranteed by openshell, so we need to add a check after any middleware hook (at least if the body/headers were modified by the middleware). I will make this change.

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent review of the current head. The blocking enforcement and credential-handling concerns are posted inline. I recommend addressing the P1 findings before merge. Code-only review; tests were not run.

&mut tls_upstream,
&ctx,
&generation_guard,
Some(&opa_engine),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Matching middleware still does not make CONNECT inspection mandatory. should_inspect_l7 above is derived only from the endpoint L7 route. For an L4-only endpoint with a matching fail-closed chain, an unsupported HTTP method or h2c preface can take the raw-copy path without invoking middleware. Please include the selected middleware chain when deciding whether inspection is required and fail closed for uninspectable protocols.


if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) {
let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?;
let req = match apply_middleware_chain(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Body-aware policy has already evaluated the original JSON-RPC request before this middleware call. An ALLOW result can replace that body with a locally denied operation, and the replacement is forwarded below without reparsing or reevaluation. Please run transformations before body-aware policy evaluation, or reparse and reevaluate the final body.

};
for endpoint in &rule.endpoints {
if endpoint.tls == "skip"
&& selector_matches_host(middleware, &endpoint.host).unwrap_or(false)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] endpoint.host may itself be a wildcard pattern, so treating it as the concrete host argument does not detect selector-pattern intersection. For example, tls: skip on *.example.com and middleware selecting api.example.com pass validation even though runtime traffic is uninspectable. Please conservatively detect pattern overlap; the corresponding runtime validation needs the same fix.

// operator service is picked up without waiting for a policy change.
// Failure preserves the last-known-good registry and does not block
// the remaining config updates below.
reconcile_middleware_registry(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The registry is reconciled before the new OPA policy is committed. Concurrent requests can therefore evaluate the old policy against the new registry. If policy reload fails, the old policy remains while the hash is still advanced below, making the mismatch persistent and allowing an unresolved fail-open guard to be skipped. Please commit policy and registry atomically, or roll back and retry both together.

return Err("host pattern must not contain whitespace".to_string());
}

let pattern = glob::Pattern::new(&pattern.to_ascii_lowercase())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] glob::Pattern allows * to cross dots, but the documented network endpoint semantics use * for one DNS label and ** for multiple labels. This can make an exclusion such as *.example.com unexpectedly suppress middleware for deep.api.example.com. Please reuse the label-aware DNS matcher and add multi-label exclusion coverage.

if name.is_empty()
|| matches!(
name.as_str(),
"authorization" | "cookie" | "host" | "content-length" | "transfer-encoding"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Proxy-Authorization is not filtered here. For non-CONNECT forward-proxy requests, remote middleware can receive Basic proxy credentials before a later forwarding step strips the header. Please remove proxy credential headers before constructing the middleware evaluation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f4c0206. Proxy-Authorization is now filtered before constructing the middleware request, with regression coverage for both origin and proxy credentials.

Comment thread proto/middleware.proto
// Destination and HTTP request target.
HttpRequestTarget target = 6;
// HTTP request headers before OpenShell injects credentials.
map<string, string> headers = 7;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] A map cannot represent repeated HTTP headers. The current parser keeps only the last value for middleware, while request rebuilding preserves all original values upstream. That creates a first-value-versus-last-value authorization differential. Please preserve repeated header values in the contract or reject ambiguous duplicates before evaluation.

.iter()
.filter(|entry| entry.is_resolved())
.map(openshell_supervisor_middleware::DescribedChainEntry::max_body_bytes)
.min()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Taking the smallest cap for the entire chain loses per-stage on_error behavior. A 2 KiB request with a 1 KiB fail-open stage followed by a 1 MiB fail-closed stage is denied before the second stage runs, even though only the first stage should be skipped. Please buffer for eligible later stages and apply capacity failures per stage.

"request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes"
));
}
if body.len().saturating_add(chunk_size) > MAX_REWRITE_BODY_BYTES {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This fixed credential-rewrite ceiling also applies to middleware chunked-body collection. A binding advertising a 1 MiB limit still rejects a 300 KiB chunked payload here. Please separate these limits or pass the negotiated middleware limit through the collector.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f4c0206. Chunked middleware collection now uses the negotiated middleware body limit instead of the 256 KiB credential-rewrite ceiling; a 300 KiB payload under a 1 MiB binding cap is covered.

serde_json::Value::Bool(value) => Kind::BoolValue(value),
serde_json::Value::Number(value) => Kind::NumberValue(
value
.as_f64()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] as_f64 accepts integers that cannot be represented exactly. For example, 9007199254740993 is delivered as 9007199254740992, which can change tenant IDs or allow/deny thresholds. Please reject values that do not round-trip exactly or use a lossless config representation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f4c0206. Integer config values that cannot round-trip exactly through protobuf double storage are now rejected, with coverage around the 2^53 boundary.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

@drew drew left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial feedback. I'll take another pass as well.

Comment thread proto/middleware.proto
// MiddlewareManifest describes one service and the bindings it exposes.
message MiddlewareManifest {
// Middleware protocol version implemented by the service.
string api_version = 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this probably isn't needed since the proto itself is versioned

Comment thread proto/middleware.proto
// Human-readable service name used for diagnostics.
string name = 2;
// Service-defined version string used for diagnostics.
string service_version = 3;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is service in this context?

Comment thread proto/middleware.proto
// ValidateConfigRequest contains one policy configuration to validate.
message ValidateConfigRequest {
// Middleware protocol version selected by OpenShell.
string api_version = 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think this is necessary if we're using protos

Comment thread proto/middleware.proto
// HttpRequestEvaluation contains one buffered HTTP request to evaluate.
message HttpRequestEvaluation {
// Middleware protocol version selected by OpenShell.
string api_version = 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, the proto already has a version

use miette::{Result, miette};

/// Binding identifier for the built-in secret redaction middleware.
pub const BUILTIN_SECRETS: &str = "openshell/secrets";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to decouple this so that we don't hardcode middleware into openshell-core? this winds up on the client and other places i think.

maybe a redaction middleware crate that is configured into the server and supervisor?

.iter()
.filter(|entry| entry.is_resolved())
.map(openshell_supervisor_middleware::DescribedChainEntry::max_body_bytes)
.min()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(agent finding)

[P2] Do not cap pre-buffering at the smallest middleware limit

When a chain mixes middleware with different max_body_bytes values, using the minimum here makes the relay treat any body larger than the smallest later middleware as unbufferable before ChainRunner can run earlier middleware that can handle it. For example, a 100 KB request with a first 256 KB redactor and a later 4 KB guard will either be denied or passed through unprocessed depending on on_error, instead of running the redactor and then applying the guard's per-entry capacity handling.

Comment thread proto/middleware.proto

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider calling this supervisor_middleware.proto.

Comment thread proto/sandbox.proto
// gRPC endpoint reachable from the sandbox supervisor.
string grpc_endpoint = 2;
// Operator-owned body limit applied to every binding exposed by the service.
uint64 max_body_bytes = 4;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent review: SupervisorMiddlewareService is introduced in this PR and doesn't exist on main, so there are no deployed supervisors to maintain wire compatibility with. Renumber max_body_bytes from 4 to 3 to close the gap before this ships — a future contributor will otherwise reuse field 3 and silently break compatibility with any supervisors running this version.

{
return false;
}
name.starts_with("x-openshell-middleware-")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent review: is_safe_append_header uses a default-deny allowlist (correct security posture), but the required x-openshell-middleware-* prefix is not documented in the proto comment on add_headers, in the operator docs, or in the error message. An operator whose service returns x-correlation-id will get unsafe_response_headers routed through on_error with no explanation of what went wrong. The error at the validate_header_mutations call site should name the required prefix, and the proto comment and extensibility docs should document this constraint explicitly.

@@ -903,6 +949,42 @@ where
let _ = &eval_target;

if allowed || config.enforcement == EnforcementMode::Audit {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent review: when enforcement == Audit, middleware still runs and a Denied result sends a hard 403 to the client. Audit mode is defined as log-but-not-block — a middleware deny should be recorded as an OCSF finding and the request allowed through, not surfaced to the client as a rejection. Either the MiddlewareApplyResult::Denied arm should be treated as an audit finding here (and at the equivalent sites at lines 462, 1226, and 1456), or this intentional override of audit semantics needs to be explicitly documented and covered by a test.

use miette::{Result, miette};

/// Binding identifier for the built-in secret redaction middleware.
pub const BUILTIN_SECRETS: &str = "openshell/secrets";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking / follow-up suggestion.

Built-ins are currently treated structurally differently from external services: they bypass connect_services, are hardwired into IN_PROCESS_SERVICE, and require special-case branches in ensure_policy_bindings_registered and policy validation (== BUILTIN_SECRETS). Adding a second built-in also requires touching match arms in two separate places across two crates (openshell-core::middleware::validate_builtin_config and builtins/mod.rs).

A trait-based approach would address this: a BuiltinMiddleware trait (covering binding_id, describe, validate_config, evaluate) with a factory function instantiate(id) -> Option<&'static dyn BuiltinMiddleware> as the single registration point. Built-ins would self-register through the same connect_services path as externals, seeding the binding ID dedup set naturally and removing the special-case branches. Adding a new built-in would then only require implementing the trait and adding one arm to instantiate.

Not a blocker for this PR, but worth tracking before a second built-in lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: implement RFC 0009 phase 1 supervisor middleware

5 participants