You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug] §6.4 conflates Guardian refusal with decision failure; refusals are attacker-reachable and should fail closed #32
Surfaced during Rock's review of PR #22 ("Guardian rejection and Guardian failure are the same thing to the adapter").
The gap
§6.4 defines a decision failure as "no usable decision arrives" — timeout, transport failure, or "the Guardian returns an error instead of a decision" — and says all three resolve to the on_decision_failure posture (default proceed).
But a JSON-RPC error response is not the same event as a silent Guardian. SIGNATURE_INVALID (-32004), REPLAY_DETECTED (-32005), TIMESTAMP_OUT_OF_WINDOW (-32006), and Invalid Request (-32600, e.g. an oversized envelope rejected before policy runs) mean the Guardian is alive and refused this envelope — and every one of them is attacker-reachable from inside the agent:
Oversize a tool argument past the Guardian's body cap → -32600 before any policy code runs → fail-open posture proceeds → the write executes ungoverned.
Replay a request_id the adapter derives deterministically (uuid5 of the framework's tool_use_id) → -32005 → same bypass.
Strip or corrupt the envelope signature → -32004 → same bypass.
Under the default fail-open posture, §6.4-as-written converts each refusal into a policy-bypass primitive.
Proposed direction
Distinguish two classes in §6.4:
Decision failure (no usable answer: timeout, transport failure, 5xx) → on_decision_failure posture, as today.
Guardian refusal (a §17.1 error meaning alive-and-refused: -32000, -32004, -32005, -32006, -32600, -32700) → the Observed Agent MUST treat as DENY regardless of posture, and MUST record an audit event.
Reference-adapter status
The PR #22 reference adapters already implement class 2 (fail closed on refusals, guardian_refusal_fail_closed audit events) as defense-in-depth ahead of the spec text — a deployment choosing a stricter per-cause posture is within its discretion. This issue tracks making the distinction normative so conformance tests can assert it.
The two-class split is right, and there is a third class sitting under it. I build and maintain a public conformance corpus for an in-toto attestation predicate, where the same distinction carries the same weight, so what follows is what that implementation hit in practice.
The framing I would put at the top of §6.4 is that the posture is answering the wrong question. on_decision_failure has two values, proceed and deny, and it routes three genuinely different events into them: the Guardian did not answer, the Guardian answered and refused, and the Guardian answered something the agent could not use. Your issue separates the first two, which is the important half. The third is the one that bites later. In my verification core the two refusing outcomes are separate fields on the same evaluation record, and the comment beside them says why they cannot be collapsed: one reports a record of the right class whose constraints were measured and not met, the other reports a record whose class this implementation cannot read at all. Both refuse, neither is ever a pass, and what differs is what the operator does next. The note I wrote beside the second one says that reporting both as unknown "tells the first producer to upgrade its verifier, which will not help." A -32004 and a body the adapter cannot parse are the same shape: the first is a decision, the second is an admission, and an operator paging on one and not the other needs them to arrive under different names.
The other thing worth writing in explicitly is what my package documentation says about its own gate, which is that on failure the artifact is invalid and its result must not be consumed. That is a consumption precondition, and stating it that way closes the gap your three primitives walk through. The oversized envelope never reached policy, so there is no verdict to honour and nothing to weigh a posture against. The sentence that fits is "there is no usable answer about this step, therefore this step does not happen." Once it is phrased as a precondition, the -32600 case stops looking like a tuning decision a deployment gets to make and starts looking like what it is.
On the enumeration itself I would take your list unchanged and add one. -32000, -32004, -32005, -32006, -32600 and -32700 are alive-and-refused and must be DENY regardless of posture. I would add -32003 CAPABILITY_NOT_NEGOTIATED, because a method exercised that the handshake did not negotiate is a Guardian saying it will not evaluate this step, which is the same event as a refusal and sits nowhere near a transport problem. -32601 and -32602 are the interesting boundary: a method-not-found or an invalid-params from a Guardian that is otherwise up is an adapter defect where the codes above are attacker-reachable, and I would still route it to DENY. Leaving it on the posture is the wrong default, because a step nobody evaluated should not execute and a noisy adapter bug is cheaper to find than a silent bypass. -32603 is the one I would leave on the posture, since an internal error genuinely can mean the Guardian fell over mid-evaluation.
Where I would push hardest is on how this gets tested, because a normative sentence here is easy to satisfy on paper and easy to get wrong in an implementation. The two events have to be distinguished by a vector that is one mutation apart, or the suite cannot tell them apart either. Concretely: the same envelope, sent twice, once oversized past the body cap so the Guardian answers -32600, and once against a Guardian that accepts the connection and never answers. Assert that the first is DENY under both postures and the second follows the posture. Without the pair, an implementation that maps every error to DENY passes, an implementation that maps everything to the posture also passes the timeout half, and neither result tells you which one you have. I would put both in the vector file on #30, since that is where the negative suite is landing, and I am happy to write them.
Whatever §6.4 ends up saying, the audit event needs to carry which class the outcome came from, as a code from your §17.1 registry rather than as prose in a message field. Message text is not a conformance surface and codes are. An incident review six months from now is going to ask whether the Guardian refused these seventeen steps or was unreachable for them, and a human-readable string will not settle that question for anybody's counsel.
This is the right split, and it points at a fourth thing underneath all three classes: the default value of on_decision_failure itself.
Everything routed to DENY is "alive-and-refused." Everything that stays on the posture lands there because the Guardian genuinely produced no usable answer: timeout, transport failure, internal error. And the consumption precondition is stated correctly: "there is no usable answer about this step, therefore this step does not happen."
But the spec's default for that class is proceed. §6.4's on_decision_failure defaults to fail-open, and PR #21 explicitly preserves that default while slimming everything else. So the one class where the Guardian is genuinely absent is the one class where the standard defaults to permission. That is accept-and-ignore from the other side: #31 is a Guardian that declares coverage and never evaluates; this is an agent that proceeds when enforcement was never confirmed. Both are permission on uncertainty.
The attacker-reachability argument from #32 applies to timeouts as strongly as to refusals. A Guardian that can be saturated or killed converts the entire control plane into a permission plane: no decision, therefore act. If the core property of this standard is preventing the agent from acting on injected reasoning, the default on "we do not know what the Guardian decided" must be "do not act."
Concrete proposal: flip the default to deny, keep proceed as an explicit opt-in for deployments that accept the availability trade. The audit event already records every fail-open proceed, so the choice remains observable; the change is only which choice a deployment must actively make. The negative suite gains a third leg on the one-mutation vector already proposed for #30: same envelope, Guardian never answers, assert DENY under the default posture.
This does not slow the adoption floor. It changes one default value and makes the safer posture the one that ships.
changed the title [-]§6.4 conflates Guardian refusal with decision failure; refusals are attacker-reachable and should fail closed[/-][+][Bug] §6.4 conflates Guardian refusal with decision failure; refusals are attacker-reachable and should fail closed[/+]on Sep 10, 2026
I agree, and the flip is measurable, which is what should carry it into the default. One ACS-Core member at probityai/agent-evidence-vectors is a timeout under the default posture, emitting acs.decision.failure with its class. It is an accept today, and under your default it becomes a reject. The member beside it stays a reject either way, because its 504 body never reached policy.
Edited 2026-09-25: the link now points at probityai/agent-evidence-vectors, where the repository lives after its transfer.
Verified against dc265475 on integration, docs/spec/instrument/specification.md at 48,524 bytes.
§4.1 already settles this split one layer up. At the handshake the spec states that a Guardian which answers with a refusal has decided, and that the startup posture applies only when no answer arrives. Reading §6.4 the same way aligns it with text that already exists, because a step that receives a §17.1 error received an answer.
§6.4 already states the principle it withholds from the codes above it. The DEFER note says a degraded Guardian that can still emit DEFER fails closed where a silent one fails open. A Guardian that can still return -32004 is the same degraded but answering Guardian. The reasoning applies to one message type and not to the refusal codes directly above it.
The registry column is where the bypass survives after §6.4 is fixed. §6.4 states that a §17.1 error carries a recovery action the agent may attempt within the remaining budget, and the registry's third column is exactly that action. The -32004 row tells the agent to re-sign the request and retry. The -32005 row answers a detected duplicate by telling the agent how to produce a non-duplicate. A deployment can adopt the class 2 rule on the §6.4 half and still be walked around on this half, because the refused envelope returns in a form that satisfies the check. The ping exemption points at the same conclusion from the other side: an ACS-specific error stays off system/ping so that liveness survives key-resolution failure, which can only matter if an up and reachable Guardian can refuse.
On #37's ordering point, I agree and will not argue it. An audit event has to exist before it can carry the posture choice, and the event shape belongs in #37. The default is separable: the flip decides which posture ships, while #37 defines the record of that choice. Either answer to #32 needs the event first.
Two asks. First, the rows for the alive-and-refused codes should state that the step does not proceed, and that a re-attempt is a new decision request judged on its own facts instead of a recovery of the refused one. Second, the vector pair for #30 should assert that the refused step produced no effect, because an implementation that retries and succeeds leaves the same trace as one that failed open.
@narko4u's concrete proposal — flip the default to deny, keep proceed as an explicit opt-in for deployments that accept the availability trade — is the part I can add a number to. The trade is named in this thread but not sized, and sizing it is the objection proceed exists to answer.
I built an out-of-tree pre-execution authorizer that defaults to fail-closed, and measured it under exactly that condition. With the policy engine unavailable, 70 replayed tool calls produced 70 denies and 0 executions. Availability of the authorizer became availability of the agent, completely. That is the cost of deny, stated as a measurement rather than a worry.
Two limits on how far it carries:
It is a replay against my own gate, not an ACS conformance run, so it measures the posture rather than this specification.
It says nothing about how often the engine is unavailable, and that frequency is what actually decides whether the trade is acceptable. I do not have that number, and I suspect it is the one the default should turn on.
Separately, and on a different axis so it does not bear on §6.4: the same enforcement point refused 3 of 36 benign held-out cases in an AgentDojo Banking run. Policy denial, not decision failure — I mention it only because "fail-closed costs nothing" is easy to assume and was not true for me.
If it would be useful, I am willing to re-run the unavailability measurement against the AGT reference implementation and file it as a conformance report under #92, so the number is against ACS rather than beside it.
#32's refusal list is JSON-RPC codes only, and misses the adapter's credential being rejected at the HTTP or TLS layer. §6.4 (bfdb898) calls that a decision failure, so under proceed an expired credential disables enforcement. Norviq, which I maintain, had the HTTP half until v0.2.2: three consecutive 401s opened a breaker whose fallback was allow.
The reference adapter has the same gap. I drove requestDecision and applyFailurePosture unmodified (Node 22, not Bun) against a stub: 401 and 403 both proceeded, because post() never reads res.status. Control: -32020 blocked.
Distinguishable from an outage: a 4xx always, a TLS alert naming the client certificate sometimes (nginx sends 400, per its source), a timeout never.
failure-posture.ts:154-158 keeps intermediary errors on the posture deliberately; a 401 is the deployment refusing the adapter. No auth mechanism needed: #32's class 2 can add a 4xx in place of a JSON-RPC response, and that alert. Happy to open the post() fix once #32 is accepted.
Activity
astrogilda commentedon Aug 21, 2026
The two-class split is right, and there is a third class sitting under it. I build and maintain a public conformance corpus for an in-toto attestation predicate, where the same distinction carries the same weight, so what follows is what that implementation hit in practice.
The framing I would put at the top of §6.4 is that the posture is answering the wrong question.
on_decision_failurehas two values,proceedanddeny, and it routes three genuinely different events into them: the Guardian did not answer, the Guardian answered and refused, and the Guardian answered something the agent could not use. Your issue separates the first two, which is the important half. The third is the one that bites later. In my verification core the two refusing outcomes are separate fields on the same evaluation record, and the comment beside them says why they cannot be collapsed: one reports a record of the right class whose constraints were measured and not met, the other reports a record whose class this implementation cannot read at all. Both refuse, neither is ever a pass, and what differs is what the operator does next. The note I wrote beside the second one says that reporting both as unknown "tells the first producer to upgrade its verifier, which will not help." A -32004 and a body the adapter cannot parse are the same shape: the first is a decision, the second is an admission, and an operator paging on one and not the other needs them to arrive under different names.The other thing worth writing in explicitly is what my package documentation says about its own gate, which is that on failure the artifact is invalid and its result must not be consumed. That is a consumption precondition, and stating it that way closes the gap your three primitives walk through. The oversized envelope never reached policy, so there is no verdict to honour and nothing to weigh a posture against. The sentence that fits is "there is no usable answer about this step, therefore this step does not happen." Once it is phrased as a precondition, the -32600 case stops looking like a tuning decision a deployment gets to make and starts looking like what it is.
On the enumeration itself I would take your list unchanged and add one. -32000, -32004, -32005, -32006, -32600 and -32700 are alive-and-refused and must be DENY regardless of posture. I would add -32003
CAPABILITY_NOT_NEGOTIATED, because a method exercised that the handshake did not negotiate is a Guardian saying it will not evaluate this step, which is the same event as a refusal and sits nowhere near a transport problem. -32601 and -32602 are the interesting boundary: a method-not-found or an invalid-params from a Guardian that is otherwise up is an adapter defect where the codes above are attacker-reachable, and I would still route it to DENY. Leaving it on the posture is the wrong default, because a step nobody evaluated should not execute and a noisy adapter bug is cheaper to find than a silent bypass. -32603 is the one I would leave on the posture, since an internal error genuinely can mean the Guardian fell over mid-evaluation.Where I would push hardest is on how this gets tested, because a normative sentence here is easy to satisfy on paper and easy to get wrong in an implementation. The two events have to be distinguished by a vector that is one mutation apart, or the suite cannot tell them apart either. Concretely: the same envelope, sent twice, once oversized past the body cap so the Guardian answers -32600, and once against a Guardian that accepts the connection and never answers. Assert that the first is DENY under both postures and the second follows the posture. Without the pair, an implementation that maps every error to DENY passes, an implementation that maps everything to the posture also passes the timeout half, and neither result tells you which one you have. I would put both in the vector file on #30, since that is where the negative suite is landing, and I am happy to write them.
Whatever §6.4 ends up saying, the audit event needs to carry which class the outcome came from, as a code from your §17.1 registry rather than as prose in a message field. Message text is not a conformance surface and codes are. An incident review six months from now is going to ask whether the Guardian refused these seventeen steps or was unreachable for them, and a human-readable string will not settle that question for anybody's counsel.
Sankalp Gilda
narko4u commentedon Aug 28, 2026
This is the right split, and it points at a fourth thing underneath all three classes: the default value of
on_decision_failureitself.Everything routed to DENY is "alive-and-refused." Everything that stays on the posture lands there because the Guardian genuinely produced no usable answer: timeout, transport failure, internal error. And the consumption precondition is stated correctly: "there is no usable answer about this step, therefore this step does not happen."
But the spec's default for that class is
proceed. §6.4'son_decision_failuredefaults to fail-open, and PR #21 explicitly preserves that default while slimming everything else. So the one class where the Guardian is genuinely absent is the one class where the standard defaults to permission. That is accept-and-ignore from the other side: #31 is a Guardian that declares coverage and never evaluates; this is an agent that proceeds when enforcement was never confirmed. Both are permission on uncertainty.The attacker-reachability argument from #32 applies to timeouts as strongly as to refusals. A Guardian that can be saturated or killed converts the entire control plane into a permission plane: no decision, therefore act. If the core property of this standard is preventing the agent from acting on injected reasoning, the default on "we do not know what the Guardian decided" must be "do not act."
Concrete proposal: flip the default to
deny, keepproceedas an explicit opt-in for deployments that accept the availability trade. The audit event already records every fail-open proceed, so the choice remains observable; the change is only which choice a deployment must actively make. The negative suite gains a third leg on the one-mutation vector already proposed for #30: same envelope, Guardian never answers, assert DENY under the default posture.This does not slow the adoption floor. It changes one default value and makes the safer posture the one that ships.
Add reference adapters: Claude Code, Cursor, NAT
[-]§6.4 conflates Guardian refusal with decision failure; refusals are attacker-reachable and should fail closed[/-][+][Bug] §6.4 conflates Guardian refusal with decision failure; refusals are attacker-reachable and should fail closed[/+]Close the governance gaps the reference implementation opened (#69)
Correct the one documentation claim that outlived its facts (#102)
astrogilda commentedon Sep 17, 2026
I agree, and the flip is measurable, which is what should carry it into the default. One ACS-Core member at probityai/agent-evidence-vectors is a timeout under the default posture, emitting
acs.decision.failurewith its class. It is an accept today, and under your default it becomes a reject. The member beside it stays a reject either way, because its 504 body never reached policy.Edited 2026-09-25: the link now points at probityai/agent-evidence-vectors, where the repository lives after its transfer.
narko4u commentedon Sep 18, 2026
Verified against
dc265475onintegration,docs/spec/instrument/specification.mdat 48,524 bytes.§4.1 already settles this split one layer up. At the handshake the spec states that a Guardian which answers with a refusal has decided, and that the startup posture applies only when no answer arrives. Reading §6.4 the same way aligns it with text that already exists, because a step that receives a §17.1 error received an answer.
§6.4 already states the principle it withholds from the codes above it. The DEFER note says a degraded Guardian that can still emit DEFER fails closed where a silent one fails open. A Guardian that can still return -32004 is the same degraded but answering Guardian. The reasoning applies to one message type and not to the refusal codes directly above it.
The registry column is where the bypass survives after §6.4 is fixed. §6.4 states that a §17.1 error carries a recovery action the agent may attempt within the remaining budget, and the registry's third column is exactly that action. The -32004 row tells the agent to re-sign the request and retry. The -32005 row answers a detected duplicate by telling the agent how to produce a non-duplicate. A deployment can adopt the class 2 rule on the §6.4 half and still be walked around on this half, because the refused envelope returns in a form that satisfies the check. The ping exemption points at the same conclusion from the other side: an ACS-specific error stays off
system/pingso that liveness survives key-resolution failure, which can only matter if an up and reachable Guardian can refuse.On #37's ordering point, I agree and will not argue it. An audit event has to exist before it can carry the posture choice, and the event shape belongs in #37. The default is separable: the flip decides which posture ships, while #37 defines the record of that choice. Either answer to #32 needs the event first.
Two asks. First, the rows for the alive-and-refused codes should state that the step does not proceed, and that a re-attempt is a new decision request judged on its own facts instead of a recovery of the refused one. Second, the vector pair for #30 should assert that the refused step produced no effect, because an implementation that retries and succeeds leaves the same trace as one that failed open.
giselleevita commentedon Sep 23, 2026
@narko4u's concrete proposal — flip the default to
deny, keepproceedas an explicit opt-in for deployments that accept the availability trade — is the part I can add a number to. The trade is named in this thread but not sized, and sizing it is the objectionproceedexists to answer.I built an out-of-tree pre-execution authorizer that defaults to fail-closed, and measured it under exactly that condition. With the policy engine unavailable, 70 replayed tool calls produced 70 denies and 0 executions. Availability of the authorizer became availability of the agent, completely. That is the cost of
deny, stated as a measurement rather than a worry.Two limits on how far it carries:
Separately, and on a different axis so it does not bear on §6.4: the same enforcement point refused 3 of 36 benign held-out cases in an AgentDojo Banking run. Policy denial, not decision failure — I mention it only because "fail-closed costs nothing" is easy to assume and was not true for me.
If it would be useful, I am willing to re-run the unavailability measurement against the AGT reference implementation and file it as a conformance report under #92, so the number is against ACS rather than beside it.
Implementation and the run: https://github.com/giselleevita/agent-security-gate
Santoshkumarpuppala commentedon Sep 28, 2026
#32's refusal list is JSON-RPC codes only, and misses the adapter's credential being rejected at the HTTP or TLS layer. §6.4 (
bfdb898) calls that a decision failure, so underproceedan expired credential disables enforcement. Norviq, which I maintain, had the HTTP half until v0.2.2: three consecutive 401s opened a breaker whose fallback was allow.The reference adapter has the same gap. I drove
requestDecisionandapplyFailurePostureunmodified (Node 22, not Bun) against a stub: 401 and 403 both proceeded, becausepost()never readsres.status. Control:-32020blocked.Distinguishable from an outage: a 4xx always, a TLS alert naming the client certificate sometimes (nginx sends 400, per its source), a timeout never.
failure-posture.ts:154-158keeps intermediary errors on the posture deliberately; a 401 is the deployment refusing the adapter. No auth mechanism needed: #32's class 2 can add a 4xx in place of a JSON-RPC response, and that alert. Happy to open thepost()fix once #32 is accepted.Answer the 12 October review of the Go Guardian