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 main after mcp-hangar#1128 / #1129 / #1130. Those three fixes are merged and not yet in a release: the current release is 2.15.0, and the rows they changed are called out in the section below. A gateway running 2.15.0 or earlier behaves as that section describes.

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 unchangedNone / "" / {} / [] are dropped before canonicalization (digest_computation._is_meaningful), so gaining description: "" or losing outputSchema to {} moves nothingpin provenance; who approved the digest
Digest mismatch / unknownthe contract moved, or was never pinned; the record carries expected, observed, enforcement, correlation_id, tenant_idthat the change is hostile; whether the caller was served or refused — read enforcement, where DigestEnforcement.BLOCK is the only blocking valueblock vs warn; the unknown policy (ALLOW_UNVERIFIED returns valid and emits no event at all)
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 then succeededwho may resolve; channel delivery; hold timeout
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 warning names the reasonthat traffic did not reach the destination by another path; that established connections were cut — they are not (conntrack, see EGRESS_POLICY)backstop flavour; pod restart after switching to Enforce
L7 deny observed (Audit)the policy would have refused: EgressPolicyViolationObserved carries the same fields, and mcp_hangar_egress_policy_violations_observed_total counts itthat 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, 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 bodyanything on a request where validation was skipped — such a request matches no selector at all and falls through to the tool rules and the policy default (ADR-025); the fall-through is visible in the verdict reason, not in the absence of oneheaders.param_validation.required, which refuses an unvalidated call rather than serving it
Tool access deniedthis caller cannot call this toolthat 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"reading the operator-side log, which carries the reason the client is not given
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 in the operator-side logreading 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 requestanything about remote endpoints declared in config.yaml (ADR-021) — and the boot warning does not enumerate them, because endpoint_is_a_literal_the_strict_policy_refuses answers False for any hostname, so a file-declared http://internal.corp/mcp is outside the policy and silentknowing that moving an upstream into the config file drops both halves
Auth 401 / 403the credential was not accepted, or the principal lacks the permissionrole mapping
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_<action>that the upstream stopped offering it — the definition is still served byte-identical upstream, only the projection dropped iton_violation, whose default warn serves the tool

Three rows that were weaker in 2.15.0

This page was drafted against 2.15.0, where three of its rows read worse. The fixes are on main and unreleased, so this is not history yet: a reader holding a record from a 2.15.0 gateway is holding the older behaviour, and so is anyone running one today.

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. A refusal by a 2.15.0 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: every L7 verdict carries a content hash of the rules that produced it, and the unfilled field is gone.

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. An approval record written by a 2.15.0 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