What a Verdict Establishes

Hangar's thesis is that every tool call ends in a verdict. This page is the other half of that: what a given verdict proves to someone reading it later — an auditor with a SIEM export, a second team holding a drift event, a reviewer of an approval record.

A verdict is three things: an outcome, a reason, and the rule that produced it. Read a record with all three, and note what is not in it. The columns below are the shape COMPLIANCE_POSTURE.md §5 already uses for the legal layer, applied to the enforcement layer: establishes / does not establish / left to the operator.

Nothing here is forward-looking. Every "establishes" claim is backed by code or an ADR, and anything not shipped appears only in the middle column.

Reviewed against 2.27.0. Every row below was re-checked against the code that release ships. What moved since the 2.25.0 review is one row: a passed SSRF check on an API-registered endpoint can now mean a private address inside a network the deployment trusts (ssrf.trusted_networks), not only a public one. 2.26.0 changed no verdict: refusing an API delete of a server the configuration declares (409 declared_in_config) moves no record.

The 2.25.0 review. What moved since the 2.24.0 review: a pinned server's catalogue is re-listed on an interval, so the digest rows no longer depend on a restart to see unannounced drift; an approval that arrives after its call was cancelled is recorded cancelled, not approved; an invalid tool access policy refuses the configuration instead of being dropped; the actor of an auth change is the authenticated caller, not the request body; and a SIEM export the gateway cannot perform refuses startup instead of dropping records. Rows corrected for it: digest mismatch, approval approved (and a new cancelled row), tool access and auth -- see what a record from before 2.25.0 lacks.

The 2.24.0 review. What moved since the 2.22.0 review is the audit side: a refused call now leaves an audit record of its own (ToolCallRefused, exported as a denied tool invocation), a group call's record names the group, an allowed call's record names the role that admitted it, and the front door's flat tools/call now checks tool:invoke. Spans gained the L7 verdict and lost the caller's own ids by default. Rows corrected for it: the listed digest (new), approval approved, L7 Enforce, L7 Audit, any L7 verdict, the Mcp-Param-* selector, tool access and auth -- see what a refusal leaves since 2.24.0. The tool access row also named the wrong log line for a front-door denial, a slip present since 2.22.0. A record is only as good as the gateway that wrote it: read what a record from before 2.25.0 lacks before trusting anything a 2.24.x or earlier gateway produced, what a record from before 2.24.0 lacks before trusting an export a 2.23.x or earlier gateway produced, and the three rows that were weaker before 2.16.0 before reading anything a 2.15.0 or earlier gateway produced.

The table

VerdictEstablishesDoes not establishOperator's side
Digest pin passedthe tool's {name, description, inputSchema, outputSchema} is byte-identical (RFC 8785 JCS) to the pinned onethe tool is safe; that annotations, execution, icons or _meta are unchanged; that the upstream implements the schema it declares; that an empty-valued field is unchanged — None / "" / {} / [] are dropped before canonicalization (digest_computation._is_meaningful), so gaining description: "" or losing outputSchema to {} moves nothingpin provenance: since 2.18.0 mcp-hangar pin --write records what a server served at a moment nobody else witnessed, so who ran it, and against which upstream is the operator's to keep; who approved the digest
pin --check clean (2.18.0)at the moment the command ran, every tool named in tool_projection.pins was served with the digest the file records — computed by compute_tool_digest, the same function the gate compares against, so a clean check and a passing call agree by construction rather than by two implementationsthat it still holds: an upstream can change between the check and the call, which is what the gate is for; anything about a tool that is served and not pinned — pins is a subset by design and an unpinned tool is not drift; that the servers behave as their schemas saywhich tools are pinned at all; running the check where it can fail loudly (exit 1) rather than only before a release
Digest mismatch / unknownthe contract moved, or was never pinned; the record carries expected, observed, enforcement, correlation_id, tenant_id. Since 2.25.0 a pinned READY server is re-listed every tool_projection.pin_recheck_interval_s (60 s by default), and the pass that first sees a drifted pin publishes one DigestMismatchEvent with correlation_id pin_recheck and logs tool_digest_pin_drift_detected, before anyone calls the toolthat the change is hostile; whether the caller was served or refused — read enforcement, where DigestEnforcement.BLOCK is the only blocking value; that a missing event means no drift -- a change is seen at most one interval plus the listing time after it happened, never with the interval at 0, not while the server is cold, degraded or DEAD, and not while its listing fails (pin_recheck_failed)block vs warn; the unknown policy (ALLOW_UNVERIFIED returns valid and emits no event at all)
Listed digest / pinned_digest (2.24.0)the tool as this listing returned it hashes to digest under compute_tool_digest, the function pin and the gate use; pinned_digest, when present, is the pin found for the id the caller named, then for the group member that served the listing -- on hangar_tools for the caller's tenant or all tenants, on GET /api/tools and GET /api/mcp_servers/{id}/tools the all-tenants pin only. A pair that differs is drift, read without the CLIthat a call will be refused or served -- the gate decides at call time, and the upstream can change between the listing and the call; that the upstream serves this schema when the server has predefined tools -- the listing, and so the digest, is the schema the configuration declares; that a missing pinned_digest means no pin -- a pin a member inherits from a group that owns it is not shown, and a per-tenant pin is not shown on the REST routes; anything about a tool the caller cannot list, which carries neither fieldreading pinned_digest against tool_projection.pins, the file that is the pin's source
Approval approvedone principal (decided_by) resolved this approval_id before expires_at; at dispatch the state, the expiry and a hash of the raw arguments were re-checked (ApprovalGateService.revalidate)that the approver saw the raw arguments — they saw a redacted copy; that the approver was competent or authorized in any legal sense; that the call was dispatched — an approval that arrives after the batch's deadline has passed reads CancellationError and is not dispatched, and since 2.25.0 is recorded cancelled instead (next row), but an approval resolved on a different instance from the one holding the call answers 200 with approved there before the holder records it cancelled; that the call then succeededwho may resolve; channel delivery; hold timeout
Approval cancelled (2.25.0)the held call's hangar_call batch was cancelled before an approval arrived, and the call did not run: the resolve answered 409 with state: "cancelled" (or, resolved on another instance, the holder recorded it), and the gate published ToolApprovalCancelled instead of ToolApprovalGranted. decided_by and the event's attempted_by name who tried to approve itthat anyone let the call through -- nothing did; anything about a call whose hold ended in a denial or an expiry after the deadline, which is still recorded as thata client or dashboard that reads every 409 as "already resolved" reading state instead
Approval expired / deniedthe call was not dispatched through this gateanything about whether it was attempted elsewhere—
L7 egress deny (Enforce)the call was refused before reaching the upstream, and the refusal is recorded: EgressPolicyEnforced carries tool, server, action, reasons, rule_kind, policy_id, correlation_id, identity_context; mcp_hangar_egress_policy_enforced_total{action,rule_kind} counts it; a batch_call_refused warning carries the verdict as bounded fields (l7_verdict, l7_mode, l7_rule_kind, l7_inspection_failed, policy_id) rather than the reasons, since 2.24.0; and since 2.24.0 the call has an audit record, ToolCallRefused, with hangar.l7.verdict, hangar.l7.mode, hangar.l7.rule_kind and hangar.l7.policy_idthat traffic did not reach the destination by another path; that established connections were cut — they are not (conntrack, see EGRESS_POLICY); the reasons — they are on the event and the aggregate's egress_policy_enforced warning only, never on a span, the batch_call_refused line or the audit record. A deny because the arguments could not be inspected (rule_kind arguments) is still refused and still in EgressPolicyEnforced, but reads as a failure everywhere else: batch_call_failed at warning, a span that ends ERROR with hangar.call.outcome=error, and no ToolCallRefusedbackstop flavour; pod restart after switching to Enforce
L7 deny observed (Audit)the policy would have refused: EgressPolicyViolationObserved carries the same fields, with would_be_action in place of action and no rule_kind, and mcp_hangar_egress_policy_violations_observed_total counts it; since 2.24.0 the call's span reads hangar.l7.verdict=audit_observedthat anything was blocked — Audit falls through and the call proceedsthe decision to switch to Enforce
Any L7 verdictwhich policy produced it: policy_id is a content hash of the compiled rules, carried by the verdict, by the refusals and by EgressPolicySet -- and since 2.24.0 by the call's span (hangar.l7.policy_id) and a refusal's audit record -- so a record and a policy change join on a value rather than on adjacent timestampsthat the rules are visible in the record — the id resolves to them only against a gateway still holding that policy (GET /api/mcp_servers/{id}/l7_policy returns policyId)keeping the policy documents that ids were computed from
L7 verdict by Mcp-Param-* selectorthe header matched a rule and the header was validated against the request body: only the front door binds a header for the policy, only one the called tool declares with x-mcp-header, only after re-running the SDK's check, and as the SDK decoded it (a =?base64?...?= sentinel included)anything on a request where a header was not checked — hangar_call (it declares no header), a handshake-era request, a skipped or failed validation, a state that does not say validation ran: none of these headers reaches a selector, and the call falls through to the tool rules and the policy default (ADR-025); the fall-through is visible as rule_kind tool and in the verdict reasons ("header rules not consulted", on the event), not in the absence of one. A checked header beside an unchecked one still decides, with that reason addedheaders.param_validation.required, which refuses a modern-revision call carrying an Mcp-Param-* header that did not reach the selector rather than serving it; a handshake-era call is served with its headers ignored
Tool access deniedthis caller cannot call this tool. Since 2.25.0 the policy behind it is the one written: a policy with an invalid field, or a list that is not a list, refuses the boot and the reload instead of being dropped or misreadthat the tool does not exist — at the front door, withdrawn, denied and unknown are all -32601, deliberately (shown equals callable, ADR-022). On the batch surface the answer differs: ToolAccessDeniedError, "Tool not available for this mcp_server". A front-door denial leaves no audit record: it is answered before the executor runs, so there is no ToolCallRefused and no batch_call_refused for itreading the operator-side log, which carries the reason the client is not given. At the front door that is front_door_tool_call at info with outcome=not_found and reason=not_projected (unknown when no upstream holds the name). On the batch surface — and at the front door for a tool denied between the listing and the call — it is batch_call_refused at warning, carrying gate=tool_access and reason=tool_not_in_access_policy (the per-gate tool_access_denied line is still written, at debug), and since 2.24.0 a ToolCallRefused audit record with the same gate and reason
Empty projection ({"tools": []})nothing about whether the caller is allowed anythingwhich of no_identity (a fail-closed deny), nothing_discovered (a replica whose warm-up has not finished or did not succeed) or filtered (the honest empty) produced it — indistinguishable from outside, classified only on the operator's side: in the log line and in the reason label of mcp_hangar_empty_projection_totalreading that log line before treating [] as a policy result
SSRF check passedthe endpoint resolved to a permitted range at registration and, for an API-registered remote server, again at connect — _SsrfGuardedTransport re-resolves and pins per request. Since 2.27.0 a permitted range includes a private network listed in ssrf.trusted_networks, checked against the list in force at each connect, so a reload that narrows it refuses the next connectionthat the endpoint is public -- with ssrf.trusted_networks set it may be any address in those networks, for every caller allowed to register a server; anything about remote endpoints declared in config.yaml (ADR-021) — the boot warning names each one, a hostname included (ssrf_policy_not_applied_to_config_file_endpoint), but it warns and does not refuseknowing that moving an upstream into the config file drops both halves; how narrow ssrf.trusted_networks is -- a Kubernetes Service range holds the kube-apiserver's ClusterIP
Auth 401 / 403, tool:invoke deniedthe credential was not accepted, or the principal lacks the permission. A tool:invoke denial of a tool call is not an HTTP status: it is Not authorized to invoke tool '<tool>': tool:invoke permission required — a failed batch entry (AuthorizationDenied) on hangar_call, and since 2.24.0 a tool error (isError) on the front door's flat tools/call — and since 2.24.0 one ToolCallRefused with gate=authorization and reason tool_invoke_denied or unauthenticated. An allowed call's audit record carries mcp.caller.roles (2.24.0): the role the allow decision matched, or opa_policyover stdio, that anyone presented a credential — the principal is the one auth.stdio.principal declares, trusted because the process was spawned (ADR-026); what the matched role grants, or granted when the call was made; any role on a trace — mcp.caller.roles is on the audit record only, and absent with auth offrole mapping; the stdio declaration. Since 2.25.0 a key or role change is recorded against the principal that made it (anonymous with auth off); before, the request body named the actor
Capability driftCapabilityViolationDetected with violation_type, violation_detail and the enforcement_action taken (alert / block / quarantine)that the drift was hostilewhich action the mode maps to
Projection withdrawala tool was withheld, and why: mcp_hangar_projection_withdrawals_total{reason} — invalid_x_mcp_header or header_exposure_withdrawthat the upstream stopped offering it — the definition is still served byte-identical upstream, only the projection dropped it; that a header_exposure_warn sample was withheld — warn counts the tool and still serves iton_violation, whose default warn serves the tool

What a refusal looks like since 2.22.0

Nothing a verdict establishes changed here. What changed is where the record is, which matters to anyone holding an export or a saved query.

One line per refusal. Every gate that refuses a call now logs batch_call_refused at warning, carrying the gate that refused and its bounded reason (mcp-hangar#1557). Before 2.22.0 each gate wrote its own line under its own name -- tool_access_denied, tool_withdrawn_rejected, tool_digest_pin_rejected, tool_digest_pin_unresolvable -- at info. Those four are still written, at debug, so a saved query keyed on one of them silently returns nothing against a 2.22.0 gateway, and against an earlier one batch_call_refused is absent for everything except the two L7 refusals. 2.24.0 narrowed the line again -- see below.

A refusal is no longer an error trace. The span a refused call leaves ends UNSET rather than ERROR, and carries hangar.call.outcome=deny, hangar.refusal.gate and hangar.refusal.reason (mcp-hangar#1556). So counting ERROR spans no longer counts refusals, in either direction: an export from 2.21.x or earlier has refusals mixed into its error traces, and one from 2.22.0 does not. The bounded error.type still names what refused on both. One exception since 2.24.0: an L7 deny because the arguments could not be inspected ends ERROR, with hangar.call.outcome=error, because the evaluator broke.

What a refusal leaves since 2.24.0

An audit record. A refused call now publishes one ToolCallRefused (mcp-hangar#1619): a batch gate that says deny (the tenant budget included, when its slot is taken after the gates), a tool:invoke denial (gate=authorization), and an L7 deny or require_approval at dispatch. The OTLP audit exporter writes it as tool_invocation with mcp.tool.status=denied, the caller and tenant fields, and hangar.gate.name and hangar.gate.reason, or the hangar.l7.* fields; CEF, LEEF, JSON lines and syslog write it as ToolInvocationDenied (event id 103 in CEF, LEEF and syslog). It carries codes only, never the refusal's text. Three things it does not establish, all of the same form -- the absence of a record:

  • Not every refusal has one. A front-door -32601, a suspended session and a refused Mcp-Param-* header are answered before the executor runs, a gate that broke rather than refused is not a refusal, and an inspection-failed L7 deny is not either. On the front door, its front_door_tool_call line is the record of the first three.
  • The publish is best-effort. One that fails is logged as tool_call_refused_not_published and the call is still refused.
  • Both exports are opt-in. The OTLP audit record is written only when an OTLP endpoint is set explicitly (OTEL_EXPORTER_OTLP_ENDPOINT or observability.tracing.otlp_endpoint) and MCP_AUDIT_EXPORT_ENABLED has not turned it off; the compliance formats only when MCP_COMPLIANCE_FORMAT is set.

A group call's record names the group. mcp.server.id on a tool_invocation record is the target the caller named, and the member that served it is hangar.route.backend (mcp-hangar#1620) -- the names spans use. A record persisted before the change replays with the member in both.

Roles are on the record, not the trace. mcp.caller.roles is the role the tool:invoke decision matched (mcp-hangar#1621), and no code path can put it on a span (mcp-hangar#1630). Spans also stopped carrying the caller's user, agent and session ids by default (mcp-hangar#1584): they are set only with observability.tracing.caller_ids: true or MCP_TRACING_CALLER_IDS=true. So a 2.24.0 trace does not say who called unless the operator opted in; the audit record does.

The log line carries no gate's text. A gate refusal's batch_call_refused lost its error field, the message the caller is told, which some gates fill with text they do not bound (mcp-hangar#1589); an L7 refusal's line keeps error, the fixed caller-facing message, and carries the verdict's bounded fields in place of the policy's reasons (mcp-hangar#1596); and a tenant-budget refusal after the gates is now logged at all, as gate=tenant_budget (mcp-hangar#1632). A call that reaches its gates after the batch's budget ran out reads batch_timeout from the global_timeout gate every time; before, it could read cancelled depending on thread timing (mcp-hangar#1591).

The trace shows the L7 verdict and the route. batch.call.<tool> carries hangar.l7.verdict (allow, audit_observed, deny, require_approval, approval_honored) with the mode, rule kind and policy id, and the route: hangar.route.backend, the member a group call went to, and hangar.route.reason (mcp-hangar#1595). An L7 refusal is marked hangar.call.outcome=deny with those fields, not with hangar.refusal.gate, which names a batch gate.

What a record from before 2.25.0 lacks

The same rule again: an export from a 2.24.x or earlier gateway has the older behaviour, whatever the rows above now say.

An approved record does not establish that the call could run. An approval that arrived after its call's batch deadline was accepted with 200, recorded approved and published as ToolApprovalGranted with the approver in decided_by, although the call was never dispatched (mcp-hangar#1702).

An auth event does not establish who made the change. created_by, revoked_by, assigned_by and updated_by came from the request body and defaulted to "system", so an admin could mint a key or grant a role and have the event, the log line and the response name someone else (mcp-hangar#1649).

A gateway that booted may have had no policy for a scope. A tools: or access: policy with one invalid field was dropped with an invalid_tools_access_config warning, and a list written as a string was read as its characters -- deny_list: add allowed add (mcp-hangar#1648, mcp-hangar#1718). A tool call it served is not evidence that the written policy allowed it; read its boot log for that warning.

A pin did not see unannounced drift. The catalogue a pin was checked against was refreshed only at start and on tools/list_changed, so a passing pin there says the tool matched what the server served when it last started (mcp-hangar#1693).

A SIEM export may be missing records without saying so. An unknown MCP_COMPLIANCE_FORMAT started the gateway with no export, and a record that could not be written was dropped with an error log line and no metric (mcp-hangar#1701). Since 2.25.0 the first refuses startup and the second is counted in mcp_hangar_compliance_export_failures_total.

What a record from before 2.24.0 lacks

The same rule as the next section: a record does not improve when the code does. An export from a 2.23.x or earlier gateway has the older behaviour.

A refusal is in no audit export. The OTLP audit exporter and the compliance formats wrote successes and upstream failures only. A refusal appeared in the log and, for some, as a domain event of its own -- EgressPolicyEnforced, ToolApprovalDenied, AuthorizationDenied -- which neither exported. Its absence from an export is not evidence that nothing was refused.

A group call's record names the member, not the group, and no record names a role: mcp.caller.roles was defined and never written.

A front-door call did not establish tool:invoke. The flat tools/call never checked it, so with auth on a principal without it -- one holding only viewer, for example -- was served what hangar_call refused it (mcp-hangar#1623).

A header verdict on hangar_call did not establish validation. Every Mcp-Param-* header on a modern request was handed to the policy as validated, including on hangar_call, where nothing checks it against the body, and a header mapping that did not state validation was read as validated (mcp-hangar#1598, mcp-hangar#1601). An earlier gateway's rule_kind=header verdict on hangar_call says a header matched, and nothing about whether the header was true.

Spans carried the caller's ids by default -- mcp.caller.id, mcp.user.id, mcp.agent.id and mcp.session.id on every authenticated call -- and no span named the L7 verdict or the member a group call went to.

Three rows that were weaker before 2.16.0

This page was drafted against 2.15.0, where three of its rows read worse. They are kept here because a record does not improve when the code does: anyone holding an export from a 2.15.0 or earlier gateway -- or still running one -- has the older behaviour, whatever this page says about the current release.

An enforced deny left almost no record. Audit mode — the mode that by definition changes nothing — emitted an event, a warning and a metric, while Enforce mode emitted a debug-level line carrying the generic caller-facing message, with the reason the policy computed left in .details where only the REST middleware looked. Fixed in mcp-hangar#1128: a refusal now publishes EgressPolicyEnforced, increments its own counter and logs at warning, since 2.16.0. A refusal by an earlier gateway is in no event stream at all — its absence from an export is not evidence that nothing was refused.

No verdict named its policy. PolicyEvaluationResult.policy_id was documented as "the policy that made the decision (for audit)" and was never set by anything; the nearest answer was a timestamp join against EgressPolicySet, which is a reconstruction rather than a record. Fixed in mcp-hangar#1129, released in 2.16.0: every L7 verdict carries a content hash of the rules that produced it, and the unfilled field is gone. A verdict from an earlier gateway names no policy, and the timestamp join is the only reconstruction available for it.

The approval copy leaked a nested secret. Argument redaction matched sensitive key names at the top level only, so {"config": {"password": …}} — and the same key inside a list of records — was persisted and served verbatim to every approval:read holder. Fixed in mcp-hangar#1130, released in 2.16.0. An approval record written by an earlier gateway may contain a secret, and the integrity hash is unaffected either way: it is computed over the raw arguments by design.

How to read a record you did not produce

  1. Find the reason, not only the outcome. Every verdict above carries one. A record with an outcome and no reason is a log line, not a verdict.
  2. Find the rule. For an L7 verdict that is policy_id; for a digest verdict the pinned digest; for tool access the policy scope in the operator-side log.
  3. Read the middle column before concluding anything. Most of the wrong conclusions available here are of the form "it did not happen because I have no record of it" — and the middle column is where this page says which records do not exist.

References