What is broken
The specification expects a runtime to substitute a disposition it cannot honor, and it is building a separate convention for each case rather than one field that records the fact.
§9.2 covers the approver-incapable client, where ask has no interactive surface. PR #21 adds §6.5 for the MODIFY-incapable client, where the Guardian "substitutes DENY with reason_codes: [\"modify_unsupported\"] and an audit event recording the substitution." Both are determined by deployment-defined means rather than a wire declaration, and §6.5 is written to mirror §9.2 deliberately.
So substitution is recorded twice, in two places, by two conventions, and in both cases the record is a reserved string inside a free-form array. substituted_from appears in zero of the 44 schemas.
Where
specification/v0.1.0/response-envelope.json, specification/v0.1.0/modifications.json, and §6.5 and §9.2 of docs/spec/instrument/specification.md
Why the wire has to carry it
ACS is one contract between a Guardian and many harnesses with different disposition support. Substitution is the steady state, not a degraded edge case, and §6.5 and §9.2 both say so by making it the defined behavior rather than an error.
The reason_codes convention works for the two cases that have one. It does not generalize, for three reasons.
It is not machine-readable as a class, and the schema says so. reason_codes is described as "Free vocabulary in v0.1 (a registry MAY be layered in a future version once patterns stabilize)." A consumer has to know that modify_unsupported means "this deny was not a policy decision" and carry that lookup for every reserved string the specification adds later, against a vocabulary the schema declares open. Adding a third substitution case means a third string and a third piece of consumer knowledge.
It does not record what was substituted. reason_codes: ["modify_unsupported"] says a modify could not be applied. It does not carry the modification the Guardian actually decided on, so the decision the Guardian made is not in the record, only the reason it could not be applied.
It collides with policy reason codes in the same array. reason_codes on a deny otherwise describes why policy denied. A substitution marker sits in the same list with nothing distinguishing the two.
Impact on implementers
An auditor replaying a chain sees a deny and cannot tell a policy deny from a runtime that substituted deny because it could not render a modify or surface an ask. Those are different facts with different follow-up. One means policy stopped an action. The other means a harness limitation stopped it and policy would have allowed a rewritten version.
This is the failure mode with the widest blast radius in a cross-harness deployment, because it looks like correct operation. A fleet where half the harnesses cannot apply modify produces a deny rate that reads as a strict policy and is actually a capability gap.
Adjacent and worth reading together rather than merging: #14 is this gap for defer specifically, and Discussion #115 argues the chain should commit the full decision record rather than only the request. If #115 lands, the decision record is the natural home for the original disposition, and this issue becomes a question about what that record is required to contain.
Separate from the case where a disposition was never eligible at a hook, which is undefined for sixteen of the twenty-two hooks and is tracked in the eligibility issue alongside this one. A substitution driven by client capability and a Guardian returning something the protocol does not define for that method are different failures and should not collapse into one field.
Verified against PR #21's head, which is where §6.5 arrives. substituted_from appears in zero of the 44 schemas there, and the §6.5 and §9.2 text is quoted from that tree, so this issue survives the merge unchanged.
Current Priority Scope
Feeds conformance evidence
What is broken
The specification expects a runtime to substitute a disposition it cannot honor, and it is building a separate convention for each case rather than one field that records the fact.
§9.2 covers the approver-incapable client, where
askhas no interactive surface. PR #21 adds §6.5 for the MODIFY-incapable client, where the Guardian "substitutesDENYwithreason_codes: [\"modify_unsupported\"]and an audit event recording the substitution." Both are determined by deployment-defined means rather than a wire declaration, and §6.5 is written to mirror §9.2 deliberately.So substitution is recorded twice, in two places, by two conventions, and in both cases the record is a reserved string inside a free-form array.
substituted_fromappears in zero of the 44 schemas.Where
specification/v0.1.0/response-envelope.json,specification/v0.1.0/modifications.json, and §6.5 and §9.2 ofdocs/spec/instrument/specification.mdWhy the wire has to carry it
ACS is one contract between a Guardian and many harnesses with different disposition support. Substitution is the steady state, not a degraded edge case, and §6.5 and §9.2 both say so by making it the defined behavior rather than an error.
The
reason_codesconvention works for the two cases that have one. It does not generalize, for three reasons.It is not machine-readable as a class, and the schema says so.
reason_codesis described as "Free vocabulary in v0.1 (a registry MAY be layered in a future version once patterns stabilize)." A consumer has to know thatmodify_unsupportedmeans "thisdenywas not a policy decision" and carry that lookup for every reserved string the specification adds later, against a vocabulary the schema declares open. Adding a third substitution case means a third string and a third piece of consumer knowledge.It does not record what was substituted.
reason_codes: ["modify_unsupported"]says amodifycould not be applied. It does not carry the modification the Guardian actually decided on, so the decision the Guardian made is not in the record, only the reason it could not be applied.It collides with policy reason codes in the same array.
reason_codeson adenyotherwise describes why policy denied. A substitution marker sits in the same list with nothing distinguishing the two.Impact on implementers
An auditor replaying a chain sees a
denyand cannot tell a policydenyfrom a runtime that substituteddenybecause it could not render amodifyor surface anask. Those are different facts with different follow-up. One means policy stopped an action. The other means a harness limitation stopped it and policy would have allowed a rewritten version.This is the failure mode with the widest blast radius in a cross-harness deployment, because it looks like correct operation. A fleet where half the harnesses cannot apply
modifyproduces a deny rate that reads as a strict policy and is actually a capability gap.Adjacent and worth reading together rather than merging: #14 is this gap for
deferspecifically, and Discussion #115 argues the chain should commit the full decision record rather than only the request. If #115 lands, the decision record is the natural home for the original disposition, and this issue becomes a question about what that record is required to contain.Separate from the case where a disposition was never eligible at a hook, which is undefined for sixteen of the twenty-two hooks and is tracked in the eligibility issue alongside this one. A substitution driven by client capability and a Guardian returning something the protocol does not define for that method are different failures and should not collapse into one field.
Verified against PR #21's head, which is where §6.5 arrives.
substituted_fromappears in zero of the 44 schemas there, and the §6.5 and §9.2 text is quoted from that tree, so this issue survives the merge unchanged.Current Priority Scope
Feeds conformance evidence