Repository navigation
Description
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.
- The SDK has no plan_hash computation at either revision.
issue_governance_authorizationonmaintakesplan_hash: strfrom the caller and embeds it verbatim. The only object a governance handler receives is a pydantic model, andAdCPBaseModel.model_dumpis not a valid preimage for the spec'sbase64url_no_pad(SHA-256(JCS(plan_payload)))rule: it synthesizeshuman_review_required: falsewhen the field was omitted, drops explicitnulls (exclude_none=Trueby default), and at 7.0.2 also rewritesAnyUrlvalues with a trailing slash. None of the spec's eleven plan-hash vectors survive a model round-trip. - Server-side JSON-schema validation is opt-in and off by default. With defaults, requests are checked only by pydantic. The generated
Budget | Budget2union rejects "neither" andreallocation_unlimited: false, but acceptsreallocation_thresholdandreallocation_unlimited: truetogether; thehuman_review_requiredallOfis not enforced at all. When schema validation is switched on, both constraints are enforced from the bundled schema and the error code isVALIDATION_ERROR; by default pydantic failures surface asINVALID_REQUEST; andGovernanceHandler's own except-branch emits a third code,VALIDATION_ERROR, on a path the dispatcher never reaches. - 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-emptycrit, so it cannot verify a spec-conformant governance token.mainaddedverify_governance_authorization(fix(governance): require crit markers with their claims, and run the signing tests CI skips #1539), which rejects unknowncritnames and enforces marker/claim pairing per the spec checklist. This item is fixed on main. - 7.0.2 predates
report_plan_adjustmentand the 3.2check_governancefields (proposed_commitment,target_agent,runtime_attestations, …). That is version lag, not a bug, andmainhas 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 explicitnull(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"] = Truev7.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.exampleinto pydantic'sAnyUrlre-serializes it ashttps://measurement.example/; one that parses2026-10-01T00:00:00.000+00:00intoAwareDatetimewrites2026-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
- Add
compute_plan_hash(plan: Mapping[str, Any]) -> strtoadcp/governance.pyusingrfc8785(already a dependency;compute_governed_payload_hashatmain:921-926is the pattern), stripping exactlyversion, status, syncedAt, revisionHistory, committedBudget, committedByType, and haveissue_governance_authorizationaccept either a precomputed hash or a plan mapping. Add a test that replays the eleven vectors already vendored undersrc/adcp/_compliance/<ver>/test-vectors/plan-hash/. - 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 thatmodel_dumpis not a preimage. - Stop synthesizing
human_review_required. Change the generated default fromFalsetoNone(the schema'sdefault: falseis a reading hint; the spec text atspecification.mdx:917forbids synthesizing it into the hash), or special-case governance plan models to drop synthesized defaults on dump. - Enforce the schema constraints in the model layer with
model_validators: rejectreallocation_thresholdtogether withreallocation_unlimited, and requirehuman_review_required: truewhenpolicy_categoriescontainsfair_housing | fair_lending | fair_employment | pharmaceutical_advertisingorpolicy_idscontainseu_ai_act_annex_iii. Alternatively defaultvalidation=ValidationHookConfig(requests="strict")for governance tools. - One error code per failure class. Map handler-level
ValidationErrortoINVALID_REQUEST(matching the dispatcher anddecisioning/dispatch.py:1547-1558) or delete the unreachable branch inserver/governance.py. - 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.pymust not be used foradcp-gov+jwstokens.
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.pyandscripts/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_dumpis not a preimage)
Related
- spec(governance): bind governance_context JWS to plan_hash adcp#2486 (plan_hash binding + vectors, 2026-04-20); feat(governance): reconcile delivery and plan adjustments adcp#6305 (
report_plan_adjustment) - fix(governance): require crit markers with their claims, and run the signing tests CI skips #1539 (crit markers), The generated AudienceEvidence can't round-trip its own content_digest: AnyUrl and AwareDatetime rewrite the digested fields #1610 (wire-text preservation), Schema-driven validation: client hooks + opt-in server middleware #249 (schema-driven validation), A nested AdCP model dumps unset optional fields as null, which 9.0.0 refuses to read back from JSON #1587 (
NoExplicitNull) - The bundled JSON Schema and the generated model disagree about the same payload, in both directions #1359 (bundled schema vs generated model disagree, closed 2026-10-24), [validation] Close generated Pydantic vs canonical JSON Schema semantic gaps #1102 (
allOf+if/thenconditionals dropped from generated models, closed 2026-09-03; thesync_planshuman_review_requiredconditional is still absent onmain), Codegen:anyOfbranch conditions are discarded — only the branches' top-levelrequiredsurvives #1414 (anyOfbranch conditions discarded) - Spec-side coherence issue being drafted separately:
sync-plans-request.jsondeclares"default": falseonhuman_review_requiredwhilespecification.mdx:917forbids synthesizing it; vector 004b suppliesnullfor atype: booleanproperty, whichmain'sNoExplicitNullJSON validation would refuse.
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
sangilish commentedon Nov 5, 2026
I ran the published
adcp==9.6.1package 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:
SyncPlansRequest.model_validate(...)followed by plainmodel_dump(mode="json")mode; 003 contains response/bookkeeping fields.create_tool_callerversus explicit strict request validationcreate_mcp_server(handler)with no validation override, using its in-processcall_toolhandle_sync_plans; the four valid controls reach it.servealso declares strict request/response defaults, although I did not start that transport.For the minimal vector, the parsed dump adds both
human_review_requiredandbudget.accounting_mode. Its expected hash isoR0jFDEtzcwgPbNf-Ofd_fZHYfAyD1TRbzGOFBVCG-c; the dump hashes to9cQE8yZ7tz119S0ATxjRPNX5mGsJYnuBTlzlD5nAxLc. 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_housingreview 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?