When false Disappears: A Serialization Bug in Agent Capability Declarations
An SDK generated valid JSON. The capability declaration was still recognizable. But two explicit choices made by its author had disappeared. One was readonly(false). The other was risk('low'). Those look like small ser
An SDK generated valid JSON. The capability declaration was still recognizable. But two explicit choices made by its author had disappeared.
One was readonly(false). The other was risk('low').
Those look like small serialization details. In the consuming runtime, however, omission activates defaults. Removing an explicit value can therefore change how a capability is interpretedβeven though parsing and schema validation succeed.
We fixed this in BailingHub Core 0.10.1, including its business integration SDKs. The useful lesson is about testing meaning across a boundary: from the author's declaration, through generated JSON, to the runtime and the package a downstream developer actually installs.
Start with the two cases. These are synthetic, abbreviated OpenAPI operation declarations, not customer incidents.
For a GET operation, the author explicitly declares that it is not readonly:
# Within an OpenAPI GET operation
x-agent-capability:
version: 1
enabled: true
scope: example.record.process
execution:
readonly: false
The old PHP 7 generator only emitted execution.readonly when the value was true and the method was not GET. This explicit false disappeared.
Under the ACC OpenAPI binding, GET supplies the readonly fallback when both execution.readonly and risk.level are omitted. An explicit value takes precedence. Under that default interpretation, the intended declaration has medium risk; the declaration with the missing false falls back to low.
A state-changing GET endpoint deserves a separate HTTP design review. That does not make it acceptable for a declaration generator to erase an explicit warning about its behavior.
The second case runs in the opposite direction:
# Within an OpenAPI POST operation
x-agent-capability:
version: 1
enabled: true
scope: example.draft.update
risk:
level: low
execution:
readonly: false
Here, the author deliberately selects low risk. The old generator emitted risk.level only when the value was not low. Once low disappeared, a non-readonly POST fell back to medium.
| Author's input | Required interpretation under these defaults | Interpretation after the value was lost |
|---|---|---|
GET, explicit readonly: false, risk omitted |
medium | low |
POST, explicit risk.level: low, not readonly |
low | medium |
Calling both cases a risk downgrade would be incorrect. Calling either a confirmed production authorization failure would also go beyond the evidence. What we established was a declaration-preservation defect and its effect on interpretation.
The difference matters because debugging often starts with a misleadingly simple question: βIs the field's value the default?β The more useful question is: βWill removing this field preserve its meaning in this context?β
For a boolean, there are at least three relevant authoring states: unset, explicitly false, and explicitly true. Unset delegates interpretation to a fallback. False supplies a value. They may happen to produce the same result in one operation, then diverge when the HTTP method or another field changes.
The same applies to enumerations. An SDK's convenient initial value is not necessarily the contract's default for every operation. Initializing risk to low and later omitting low silently combines two decisions: what an author selected, and what a consumer should infer when no selection exists.
In the fixed PHP 7 generator, the relevant check for readonly is now about presence:
if (array_key_exists('readonly', $o)) {
$execution['readonly'] = $o['readonly'];
}
The authoring representation retains whether the option was set. Explicit risk values are serialized too. Unset fields remain omitted, so the existing contract defaults still apply. We applied the same distinction to explicit idempotent values; this preserves the declaration, without claiming that an idempotence hint alone makes an uncertain business operation safe to retry.
The fix also needed the consumer. BailingHub had paths that inferred readonly from GET even when an explicit false existed. A perfect producer cannot compensate for a consumer that overwrites its output. The precedence must survive both sides of the boundary: explicit declarations first, documented binding defaults only when the relevant value is absent.
That led to a small, systematic test matrix:
HTTP method: GET | POST
readonly: omitted | false | true
risk: omitted | low | medium | high
2 Γ 3 Γ 4 = 24 combinations
The PHP 7 regression calls the real ToolSpec builder and ToolDef setters, then parses the actual JSON emitted by buildJson(false). It does not test a copied serialization helper.
Each combination checks two different things. First, did the emitted declaration preserve the presence and exact value of every explicit option, while leaving unset options absent? Second, does interpreting that emitted declaration produce the expected risk derived from the original inputs and documented precedence?
Those checks are complementary. A schema validator can accept a declaration that has lost a meaningful optional field. An effective-risk assertion alone can miss a lost field when the current fallback happens to produce the same answer. Checking both catches accidental agreement as well as observable disagreement.
The suite also includes six idempotence cases and a declaration with explicit low risk, explicit false values, subject requirements and conditional approval. That last case checks that preserving a low risk label does not remove unrelated governance requirements. Runtime regressions separately exercise consumption; generator tests are not evidence that a remote business system enforces authorization correctly.
The release verification recorded 171 passing checks for the PHP 7 suite on PHP 7.3.33, both against source and against the extracted distribution package. The published cross-SDK regression and release notes cover the wider repair across PHP 7, PHP 8, Node.js, Python, Go, Java and .NET. These are scoped regression results, not a security certification of the whole system.
To inspect the PHP 7 matrix from a checkout of the release tag:
php sdk/php7/tests/declaration-serialization.php
The script also accepts an SDK root, allowing the same test to load the extracted package's source. That distinction is worth keeping: a repaired repository and a repaired downloadable package are separate facts. Downstream teams consume artifacts, not the maintainer's working directory.
Upgrading requires a little interpretation too. An explicitly low-risk POST may now appear as low where the buggy path previously reported medium. Its owner should confirm that the original low classification was intentional. The repair should not become a blanket rule that all writes are low risk, and it should not rewrite historical approval records. The upgrade notes describe updating Core and the relevant SDK together and reviewing regenerated declarations.
Risk classification is not a grant of business authority. Subject requirements, approval conditions and the business system's final permission checks remain separate responsibilities. Preserving an author's value also does not prove that the author classified the operation correctly.
I maintain ACC and BailingHub. ACC provides a shared declaration vocabulary for agent-facing business capabilities; BailingHub is an open-source implementation that consumes those declarations. This repair changed implementation behavior to preserve existing semantics. It did not introduce a new ACC declaration version: version: 1 remains unchanged.
For teams connecting agents to existing systems, this is a practical part of making a contract useful. The documented rule has to survive every adapter, builder and distribution boundary on its way to execution. Valid JSON tells us that a document can be read. We still need evidence that it says what its author actually declared.
If you maintain a tool or capability SDK, how do your tests distinguish βunsetβ from βexplicitly set to the defaultββespecially after the declaration has crossed into another language?
Writing disclosure: This article was drafted with AI assistance using the project's published code, release notes and recorded regression results, with source checking and editorial revision performed in the same assisted workflow. The examples are synthetic; no customer data is included.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.