Skip to content

KillSwitchMiddleware stores nested struct in operation.Metadata — flatten to scalars for parity with other middlewares? #2168

@finnoybu

Description

@finnoybu

Observation

KillSwitchMiddleware writes a full KillSwitchDecision struct (which embeds *KillSwitchScope and *KillSwitchEvent) into operation.Metadata["kill_switch_decision"]:

capability := defaultString(operation.ToolName, operation.Action)
decision := registry.DecisionFor(operation.AgentID, capability)
ensureOperationMetadata(operation)
operation.Metadata["kill_switch_decision"] = decision

The other middlewares that touch operation.Metadata mostly store small scalars or single-purpose values:

Middleware Stored value
KillSwitchMiddleware KillSwitchDecision (allowed + scope ptr + event ptr)
PolicyEvaluationMiddleware PolicyDecision (string type alias)
PromptDefenseMiddleware PromptDefenseResult (small struct)
AuditTrailMiddleware string (the audit entry hash)
(HTTP) agent_identity_error string

KillSwitchDecision does JSON-marshal cleanly (all fields are exported and tagged), so this isn't a serialisation bug. The concern is consistency / consumer ergonomics: downstream consumers that read operation.Metadata["kill_switch_decision"] after encoding/json round-trip will get map[string]interface{} rather than the typed struct, since Metadata is map[string]interface{}.

Suggestion

If Metadata is intended to be a JSON-serialisable diagnostic bag, consider flattening the kill-switch entry to scalar keys, e.g.:

operation.Metadata[\"kill_switch_allowed\"] = decision.Allowed
if decision.Scope != nil {
    operation.Metadata[\"kill_switch_scope\"] = decision.Scope.String()
}
if decision.Event != nil {
    operation.Metadata[\"kill_switch_reason\"] = string(decision.Event.Reason)
}

Or, if richer state is intentional, keep the struct but document operation.Metadata as containing Go-side typed values (not just JSON scalars), so other middlewares can follow the same pattern.

I'm raising this as an issue rather than a PR because the answer is a maintainer judgment call: the current behaviour is functionally fine, the question is whether Metadata is a typed bag or a JSON bag.

Repro

registry := agentmesh.NewKillSwitchRegistry()
_, _ = registry.Activate(
    agentmesh.AgentKillSwitchScope(\"agent-1\"),
    agentmesh.KillSwitchReasonSecurityIncident,
    \"test\",
)
stack, _ := agentmesh.CreateGovernanceMiddlewareStack(agentmesh.MiddlewareStackConfig{
    Policy: agentmesh.NewPolicyEngine(nil),
    KillSwitches: registry,
})
op := &agentmesh.GovernedOperation{AgentID: \"agent-1\", Action: \"tool.run\"}
_ = stack.Execute(op, func(*agentmesh.GovernedOperation) error { return nil })
fmt.Printf(\"%T\n\", op.Metadata[\"kill_switch_decision\"])
// agentmesh.KillSwitchDecision

Surfaced during independent audit conducted by @finnoybu (Ken Tannenbaum, AEGIS Initiative); [LOW, Go].

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions