Skip to content

governance: no plan_hash helper, model_dump is not a JCS-safe preimage, and sync_plans budget oneOf / human-review allOf go unenforced by default (7.0.2; partly open on main) #1852

Open

Description

@lukasz-pubx

Summary

Verified against tag v7.0.2 (0ab9971f9d3c4b0ee3d55a1300c16b6886d22535, bundles ADCP_VERSION 3.1.15) and main (8da31ccfdc7f484cd19d78bd1a64ff5791eef153, 9.6.1, bundles 3.2.7), with the spec repo pinned at adcontextprotocol/adcp@ded86cd94361f0e4eb88ac21e5e7be67f00106d7.

  1. The SDK has no plan_hash computation at either revision. issue_governance_authorization on main takes plan_hash: str from the caller and embeds it verbatim. The only object a governance handler receives is a pydantic model, and AdCPBaseModel.model_dump is not a valid preimage for the spec's base64url_no_pad(SHA-256(JCS(plan_payload))) rule: it synthesizes human_review_required: false when the field was omitted, drops explicit nulls (exclude_none=True by default), and at 7.0.2 also rewrites AnyUrl values with a trailing slash. None of the spec's eleven plan-hash vectors survive a model round-trip.
  2. Server-side JSON-schema validation is opt-in and off by default. With defaults, requests are checked only by pydantic. The generated Budget | Budget2 union rejects "neither" and reallocation_unlimited: false, but accepts reallocation_threshold and reallocation_unlimited: true together; the human_review_required allOf is not enforced at all. When schema validation is switched on, both constraints are enforced from the bundled schema and the error code is VALIDATION_ERROR; by default pydantic failures surface as INVALID_REQUEST; and GovernanceHandler's own except-branch emits a third code, VALIDATION_ERROR, on a path the dispatcher never reaches.
  3. 7.0.2 has no governance JWS verifier at all. The only JWS code (signing/jws.py) is for revocation lists and rejects every non-empty crit, so it cannot verify a spec-conformant governance token. main added verify_governance_authorization (fix(governance): require crit markers with their claims, and run the signing tests CI skips #1539), which rejects unknown crit names and enforces marker/claim pairing per the spec checklist. This item is fixed on main.
  4. 7.0.2 predates report_plan_adjustment and the 3.2 check_governance fields (proposed_commitment, target_agent, runtime_attestations, …). That is version lag, not a bug, and main has them; it is listed so adopters pinned to 7.x know.

Current state (verbatim quotes)

Spec requirement

adcontextprotocol/adcp@ded86cd docs/governance/campaign/specification.mdx:915-917:

plan_hash = base64url_no_pad(SHA-256(JCS(plan_payload))) where:
… JCS sorts object keys lexicographically by code point … and preserves the distinction between an omitted optional field and an explicit null (these produce different hashes). Governance agents MUST hash the plan as-supplied; they MUST NOT synthesize omitted optionals to default values and MUST NOT drop explicit nulls.

specification.mdx:984: "Eleven vectors under static/compliance/source/test-vectors/plan-hash/ pin the canonicalization bit-exactly … Governance agents and auditor verifiers MUST reproduce these hashes bit-exactly."

docs/building/by-layer/L1/security.mdx:778 (checklist step 4): "Reject if the header contains a crit array and any listed protected-header extension is not recognized by the verifier. For authorized_commitment, authorized_task, and authorized_payload_hash, require each boolean protected-header marker, its crit entry, and its payload claim to appear together."

1. No plan-hash helper; model_dump is lossy

v7.0.2 — grep -rn plan_hash src/adcp --include='*.py' outside generated types: no results.
main — src/adcp/governance.py:1011 plan_hash: str, and :1064 "plan_hash": plan_hash, (caller-supplied, embedded as-is). No function computes it.

v7.0.2 src/adcp/types/base.py:269-280:

    def model_dump(self, **kwargs: Any) -> dict[str, Any]:
        ...
        if "exclude_none" not in kwargs:
            kwargs["exclude_none"] = True
        if "serialize_as_any" not in kwargs:
            kwargs["serialize_as_any"] = True

v7.0.2 src/adcp/types/generated_poc/governance/sync_plans_request.py:

42:    total: Annotated[float, Field(description='Total authorized budget.')]
130:    start: Annotated[AwareDatetime, Field(description='Flight start (ISO 8601).')]
146:    agent_url: Annotated[AnyUrl, Field(description='URL of the delegated agent.')]
286:    human_review_required: Annotated[
287:        bool | None,
...
291:    ] = False
298:    approved_sellers: Annotated[
299:        list[AnyUrl] | None,

main src/adcp/types/domains/governance/sync_plans_request.py:341 still ] = False for human_review_required. main src/adcp/types/_wire.py:1-10 fixes the URL / datetime rewrites for wire text (#1610):

A model that parses https://measurement.example into pydantic's AnyUrl re-serializes it as https://measurement.example/; one that parses 2026-10-01T00:00:00.000+00:00 into AwareDatetime writes 2026-10-01T00:00:00Z. Either rewrite changes the digested bytes …

Vector run. The eleven spec vectors all reproduce with rfc8785 over expected.preimage (11/11 PASS). Pushed through a pydantic model mirroring the generated annotations above (same types, same exclude_none=True default; the sandbox would not execute the cloned package, so the SDK class itself was not run):

vector result cause
001-minimal-plan FAIL human_review_required: false synthesized
004a-human-review-omitted FAIL human_review_required: false synthesized
004b-human-review-explicit-null FAIL explicit null dropped (exclude_none=True); with exclude_none=False it is emitted as null only because the mirror has no NoExplicitNull marker
002-full-plan FAIL (by reading) approved_sellers https://publisher-a.example → https://publisher-a.example/ at 7.0.2 (AnyUrl); fixed on main by _wire.py
003, 005a/b, 006a/b, 007, 008 FAIL (by reading) all omit human_review_required, so false is synthesized

Individual coercions observed on the mirror: total: 100000 → 100000.0 in Python but rfc8785 still emits 100000 (JCS-neutral; only non-JCS hashing is affected); 2026-06-30T00:00:00+00:00 → 2026-06-30T00:00:00Z; https://seller.example → https://seller.example/; human_review_required: null → key absent; omitted → false.

2. Schema validation opt-in; union and allOf enforcement

v7.0.2 src/adcp/server/mcp_tools.py:2225-2229:

def create_tool_caller(
    handler: ADCPHandler[Any],
    method_name: str,
    *,
    validation: ValidationHookConfig | None = None,

:2251-2260 (docstring): "Schema-driven validation (issue #249). When validation is supplied, the dispatcher validates incoming requests and outgoing responses against the bundled AdCP JSON schemas. Request failures raise ADCPTaskError(VALIDATION_ERROR) before the handler runs … Defaults to off on the server side".

:2597-2608 — schema path (only when enabled) raises VALIDATION_ERROR; :2616-2675 — pydantic path (always) raises Error(code="INVALID_REQUEST", field=field_path or None, …); :2676 result = await method(call_params, ctx) passes the model instance to the handler.

v7.0.2 src/adcp/server/governance.py:94-101:

        try:
            request = SyncPlansRequest.model_validate(params)
        except ValidationError as e:
            return NotImplementedResponse(
                supported=False,
                reason=f"Invalid request: {e}",
                error=Error(code="VALIDATION_ERROR", message=str(e)),
            )

Unreachable from the dispatcher (it already validated and passes a model); same code on main src/adcp/server/governance.py:110-302.

Bundled schema v7.0.2 schemas/cache/3.1/governance/sync-plans-request.json:95 "oneOf": [ and :375 "allOf": [ — the constraints are present and are enforced when schema validation is on.

Generated union v7.0.2 sync_plans_request.py:48-60 (Budget): reallocation_threshold: Annotated[float, …] required, reallocation_unlimited: Annotated[bool | None, …] = None; :69-91 (Budget2): reallocation_threshold: … | None = None, reallocation_unlimited: Annotated[Literal[True], …]; :221 budget: Annotated[Budget | Budget2, …]. Consequence: {threshold: 5000, reallocation_unlimited: true} validates as Budget (confirmed on the mirror: ACCEPTED), although the schema's oneOf branch 1 has "not": {"required": ["reallocation_unlimited"]}. Plan has no validator for the policy_categories / policy_ids → human_review_required: true conditionals. Same shape on main domains/governance/sync_plans_request.py:67-76, 108-120 (StrictFloat/StrictBool, but still optional on Budget).

3. crit handling

v7.0.2 src/adcp/signing/jws.py:1 """Minimal JWS parse + verify for AdCP revocation lists. and :166-168:

    crit = header.get("crit")
    if crit is not None and (not isinstance(crit, list) or len(crit) > 0):
        raise JwsMalformedError("JWS 'crit' header is not supported for this profile")

grep -rn 'adcp-gov+jws' src/adcp at v7.0.2: no results. No governance token verifier exists in 7.0.2.

main src/adcp/governance.py:1154-1169:

    critical = header.get("crit")
    if (
        not isinstance(critical, list)
        or any(not isinstance(name, str) for name in critical)
        or len(set(critical)) != len(critical)
        or any(name not in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS for name in critical)
    ):
        return reject("governance_token_invalid", "invalid governance JWS crit header")
    for name in GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS:
        # security.mdx checklist step 4: the marker, its crit entry, and its
        # claim appear together, and the marker is true.
        together = (name in claims) == (name in critical) == (name in header)
        if not together or header.get(name, True) is not True:

CHANGELOG.md:351: "governance: require crit markers with their claims, and run the signing tests CI skips (#1539)". Conformant on main.

4. Version lag

v7.0.2 src/adcp/ADCP_VERSION = 3.1.15; src/adcp/_version.py:30 COMPATIBLE_ADCP_VERSIONS: tuple[str, ...] = ("3.0", "3.1"). report_plan_adjustment: no file under src/adcp/types/generated_poc/governance/ (spec added it 2026-08-17 in adcontextprotocol/adcp#6305, the day v7.0.2 was tagged); present on main as src/adcp/types/domains/governance/report_plan_adjustment_request.py. check-governance-request.json top-level properties at spec v3.1.15 vs main: main adds budget_period_id consultation_context execution_commitment proposal proposed_commitment runtime_attestations target_agent. Governance schema diff between spec v3.1.15 and v3.1.24 is two one-line changes in response files, so 7.0.2 does match the 3.1.24 request schemas.

Problem narrative

A governance agent built on GovernanceHandler gets SyncPlansRequest as a model, never the raw dict, because the dispatcher validates first and passes the instance. The obvious way to produce plan_hash is plan.model_dump() plus a JCS library. That dump has already synthesized human_review_required: false, dropped any explicit null, and, on 7.0.2, re-serialized every AnyUrl. The spec says the hash is forever-binding audit evidence and that two implementations that disagree on the preimage silently void that audit layer; it even ships vectors that "MUST" reproduce bit-exactly. An SDK adopter cannot reproduce them from what the SDK hands them, and nothing in the SDK or its docs says so.

On the validation side, the bundled schema does encode the budget xor and the human-review conditional, but the dispatcher does not use it unless the adopter discovers ValidationHookConfig. The default pydantic path accepts a budget with both reallocation_threshold and reallocation_unlimited: true and accepts a fair_housing plan without human_review_required: true, which is the one plan-level flag the spec ties to GDPR Art 22 / EU AI Act. Three different error codes for the same class of failure (INVALID_REQUEST by default, VALIDATION_ERROR when schema validation is on, VALIDATION_ERROR again from the handler's dead branch) make client-side recovery logic guess.

The crit gap is closed on main, but 7.x users have no verifier, and the one JWS verifier the package ships would reject a valid governance token outright.

Proposed change

  1. Add compute_plan_hash(plan: Mapping[str, Any]) -> str to adcp/governance.py using rfc8785 (already a dependency; compute_governed_payload_hash at main:921-926 is the pattern), stripping exactly version, status, syncedAt, revisionHistory, committedBudget, committedByType, and have issue_governance_authorization accept either a precomputed hash or a plan mapping. Add a test that replays the eleven vectors already vendored under src/adcp/_compliance/<ver>/test-vectors/plan-hash/.
  2. Give governance handlers the as-supplied plan. Either expose the raw wire dict on the handler context for sync_plans / check_governance, or keep per-plan raw JSON on the request model (a private attribute set by the dispatcher) so adopters do not hash a dump. Document that model_dump is not a preimage.
  3. Stop synthesizing human_review_required. Change the generated default from False to None (the schema's default: false is a reading hint; the spec text at specification.mdx:917 forbids synthesizing it into the hash), or special-case governance plan models to drop synthesized defaults on dump.
  4. Enforce the schema constraints in the model layer with model_validators: reject reallocation_threshold together with reallocation_unlimited, and require human_review_required: true when policy_categories contains fair_housing | fair_lending | fair_employment | pharmaceutical_advertising or policy_ids contains eu_ai_act_annex_iii. Alternatively default validation=ValidationHookConfig(requests="strict") for governance tools.
  5. One error code per failure class. Map handler-level ValidationError to INVALID_REQUEST (matching the dispatcher and decisioning/dispatch.py:1547-1558) or delete the unreachable branch in server/governance.py.
  6. Docs/backport note for 7.x: state that no governance JWS verifier exists before the fix(governance): require crit markers with their claims, and run the signing tests CI skips #1539 series and that signing/jws.py must not be used for adcp-gov+jws tokens.

Files affected

  • src/adcp/governance.py (new helper; issue_governance_authorization)
  • src/adcp/server/governance.py (error code / dead branch)
  • src/adcp/server/mcp_tools.py (raw-params exposure; validation default for governance tools)
  • src/adcp/types/domains/governance/sync_plans_request.py and scripts/post_generate_fixes.py (default, validators)
  • src/adcp/types/base.py (if a governance-specific dump is preferred)
  • tests/test_governance_authorization.py (plan-hash vector replay)
  • docs/ server guide (schema validation is opt-in; model_dump is not a preimage)

Related

Verification record

claim v7.0.2 main verdict
a. never JSON-schema validates; rejections are INVALID_REQUEST; budget oneOf / human-review allOf unenforced schema validation exists but is off by default (mcp_tools.py:2229); default path is INVALID_REQUEST; handler branch says VALIDATION_ERROR; oneOf half-enforced by union (both-set accepted), allOf unenforced same partly held
b. model dump is a lossy preimage (int→float, timestamps, URL slash, nulls) no hash function at all; nulls dropped, false synthesized, URL slash added; int→float is JCS-neutral; +00:00→Z only no hash function; URL/datetime fixed by _wire.py; nulls and false still held in substance, two sub-claims overstated
c. validates crit per L1 no governance verifier; generic JWS rejects any crit governance.py:1154-1169 rejects unknown names, enforces pairing did not hold at 7.0.2; fixed on main
d. other divergences no report_plan_adjustment; 3.2 check_governance fields absent; error-code inconsistency version lag closed; error-code inconsistency remains held (version lag + error codes)

Provenance: findings verified by reading the pinned revisions above and by replaying the spec's plan-hash vectors with rfc8785 plus a pydantic model mirroring the generated SyncPlansRequest annotations (the SDK package itself was not executed in the verification sandbox, so the "FAIL (by reading)" rows are inferred from the generated annotations, not observed). Written with Claude Code assistance and reviewed before posting; line numbers are at the named SHAs/tags and will drift.

Activity

  1. sangilish commented on Nov 5, 2026

    @sangilish
    Contributor

    I ran the published adcp==9.6.1 package rather than a mirrored model: Python 3.12, Pydantic 2.13.5, rfc8785 0.1.4, bundled spec 3.2.7. The eleven source vectors were pinned to adcp@ded86cd.

    The hash-preimage concern reproduces, but the validation-default claim needs a narrower scope:

    Boundary exercised Result
    Raw as-supplied mapping, stripping exactly the six bookkeeping fields, then RFC 8785 + SHA-256 + unpadded base64url All eleven expected hashes reproduce. This does not mean all eleven are valid requests.
    Actual SyncPlansRequest.model_validate(...) followed by plain model_dump(mode="json") Nine vector plans parse; all nine dumps change the hash. The other two are rejected: 002 has an unsupported channel value and extra mode; 003 contains response/bookkeeping fields.
    Default create_tool_caller versus explicit strict request validation The low-level default permits regulated omitted/false review flags, explicit null, and both budget controls; strict validation rejects them before the handler. Neither budget control is rejected on both paths, with different error codes.
    create_mcp_server(handler) with no validation override, using its in-process call_tool Its default already enforces strict request validation: the same five invalid cases are blocked before handle_sync_plans; the four valid controls reach it. serve also declares strict request/response defaults, although I did not start that transport.

    For the minimal vector, the parsed dump adds both human_review_required and budget.accounting_mode. Its expected hash is oR0jFDEtzcwgPbNf-Ofd_fZHYfAyD1TRbzGOFBVCG-c; the dump hashes to 9cQE8yZ7tz119S0ATxjRPNX5mGsJYnuBTlzlD5nAxLc. The same mutation occurs inside the handler even when strict request validation is enabled. Thus enabling validation alone does not make a plain model dump a valid hash preimage.

    I exercised nine inputs on each of three paths (27 dispatches): ordinary review omitted/false/true/null; fair_housing review omitted/false/true; and budget both/neither controls. The explicit-null vector reproduces as a hash vector, while the strict request paths reject it. I would keep those as separate assertions rather than relax the request schema to make that vector pass. These were local, in-process calls, not an HTTP/A2A interoperability or live-agent test; I did not rerun 7.0.2 or the JWS verifier.

    Could I take a bounded regression-test slice here: existing raw-vector replay plus valid-request raw-input-to-handler/hash preservation, with low-level and public-factory validation controls kept separate? I would leave the helper/SDK implementation and the policy choices in adcontextprotocol/adcp#8733 with their current owners, and avoid changing generated defaults as a substitute for preserving submitted input. Is that test slice already in progress?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions