CX-3 Report — Google provider contracts (WO-24, WO-25) — 2026-09-15

Prompt: CX-3 (docs/audits/AUDIT_EXECUTION_PROMPTS_2026-09-08.md) — WO-21, WO-24, WO-25. Branch: audit/cx-3-perimeter-providers, based on 1f601d171 (origin/main, 2026-09-15). Scope executed here: WO-24 and WO-25 only. WO-21 was built elsewhere on claude/boss-chat-auth-ext-0915 (require_chat_caller in miz-oki-adk-agents/boss/boss_agent_core.py) and is not touched by this branch (git diff --stat origin/main HEAD -- miz-oki-adk-agents/boss is empty). PR URL: to be opened by the coordinator. Sandbox: no gh, no gcloud, no network to Google. PyPI was reachable and was used only to read google-ads client-library metadata (pip download --no-deps); no live provider call was made anywhere in this pack.


WO-24 — Google Ads adapter: leave sunset v21 — fixed (Step 0 partly not reproduced)

Files: services/service-action-runner/execution_adapters/google_ads.py (credentials.py inspected: version-agnostic, unchanged); tests/remediation/test_wo24_google_ads_api_version.py; tests/remediation/fixtures/google_ads/{MANIFEST,campaign_budget,campaign_status,ad_group_bid,keyword_bid,keyword_status}.json.

Step 0 on 1f601d171. The audit's counterexample — default v21 — is not reproduced: commit 2cea2e12f (2026-09-14, "WO-24 — Google Ads pins off sunset versions") had already moved the default to v23 and added the repo-wide guard tests/governance/test_google_ads_api_version_sunset.py. What was still open on the base, measured by running the new test file against it (first run: 14 failed, 26 passed):

Version determination (no network to Google). The newest supported version was read from the google-ads client library metadata on PyPI, not from Google's sunset page:

pip index versions google-ads            -> newest 32.0.0
google_ads-32.0.0-py3-none-any.whl : google/ads/googleads/client.py
    _VALID_API_VERSIONS = ["v25", "v24", "v23", "v22", "v21"]
    _DEFAULT_VERSION   = _VALID_API_VERSIONS[0]        # v25
wheel package dirs: googleads/{v21,v22,v23,v24,v25}/

Google's release notes (per docs/audits/wo/WO-24.md: v25 GA 2026-07-22, v25.1 2026-08-19; a minor serves on the same /v25/ REST endpoint) and the library agree on v25. The sunset dates used (v21 2026-08-05, v22 2026-10-07, ≤v20 on/before 2026-06-10) are the ones the existing governance guard cites from Google's developer-blog posts; they were checked against library metadata, not Google's page — the library still lists v21/v22, so the dated table remains the authority on sunset, the library on support.

Fix. - DEFAULT_API_VERSION = "v25"; API_VERSION = os.environ.get("GOOGLE_ADS_API_VERSION", DEFAULT_API_VERSION) — override kept. - Committed LIBRARY_SUPPORTED_API_VERSIONS = (v25, v24, v23, v22, v21), SUNSET_API_VERSIONS = {v14..v21}, SUNSET_SCHEDULE = {"v22": "2026-10-07"}. - check_api_version() — refusal path: a sunset or library-unknown override raises AdapterNotConfigured inside _credentials_for, i.e. before any request is built, for every read, mutate, dry-run and rollback. Tested with transport.calls == []. - validate_configuration() / health() now report api_version_policy. - Library pin: the WO asks for a clearly marked pin in the runner's requirements. The action runner has no requirements file and imports no Google SDK by design (REST + stdlib; deploy-service-action-runner.yml says not to add a dependency without revisiting ops/remediation/constraints.txt). A pin for a library the image does not install would be a false claim, so the evidence is carried as the LIBRARY_SUPPORTED_API_VERSIONS tuple with its provenance comment, and the sunset guard + this test hold it. No change to CX-4's lockfile lane. - Changelog audit of every mutation and read-back call was done offline against the v25 protos inside the wheel (google/ads/googleads/v25/**): campaign_budget.{resource_name,name,amount_micros}, campaign.{resource_name,name,status}, ad_group.{resource_name,name,cpc_bid_micros}, ad_group_criterion.{resource_name,status,negative,cpc_bid_micros,keyword.text,keyword.match_type}, Mutate*Request.{operations,validate_only,response_content_type}, *Operation.{update_mask,update}, enums CampaignStatus/AdGroupCriterionStatus {ENABLED,PAUSED,REMOVED}, ResponseContentType.MUTABLE_RESOURCE. No field or enum the adapter sends or reads was renamed in v25; nothing needed fixing. The table is pinned as V25_FIELDS in the test so a later rename fails the build.

Acceptance tests (tests/remediation/test_wo24_google_ads_api_version.py, 40 passed): test_wo24_default_version_is_the_newest_library_supported_version, ..._is_not_in_the_committed_sunset_list, ..._sunset_list_agrees_with_the_repo_wide_sunset_guard, ..._source_literal_default_is_v25, ..._env_override_is_kept, ..._a_sunset_override_is_refused_before_anything_is_sent, ..._an_unknown_override_is_refused_too, ..._health_and_configuration_report_the_version_posture; parametrized over all five actuators: ..._read_back_parses_the_recorded_v25_search_response, ..._mutation_is_sent_to_the_pinned_version_and_proven_by_read_back, ..._rollback_restores_the_recorded_prior_value, ..._validate_only_path_sends_validateonly_and_applies_nothing, ..._already_at_target_is_an_idempotent_replay, ..._every_mutated_field_and_enum_exists_in_v25; plus ..._every_gaql_field_the_adapters_read_exists_in_v25, ..._fixture_manifest_says_the_fixtures_are_synthetic. Existing suites kept green: tests/remediation/test_execution_adapters.py 150, tests/governance/test_google_ads_api_version_sunset.py 3.

Fixture honesty. The recorded responses are SYNTHETIC recorded-shape fixtures derived from the proto3-JSON mapping of the v25 protos (int64 as strings, enums as strings, camelCase), labelled so in MANIFEST.json. They are not captures from a Google Ads account.

Deferred / OPS checklist (not performed — needs a test account and a human): 1. In a Google Ads test account, with EXECUTION_ADAPTERS_ENABLED left false in production, run each actuator's dry_run (validateOnly: true) against /v25/ and confirm HTTP 200 with an empty body; then one read-back (googleAds:search) per resource. Record the request ids in this report. 2. Replace the synthetic fixtures with the redacted captures from step 1 and drop the SYNTHETIC label in MANIFEST.json in the same commit. 3. Before 2026-10-07: nothing in this adapter pins v22, but tests/governance/test_google_ads_api_version_sunset.py will start failing for any tree-wide v22 pin 14 days ahead — the rails/GAQL owners act on it.

Adjacent finding (not my file, not changed): src/cells/google_ads_gaql/requirements.txt pins google-ads==25.1.0, whose wheel contains only googleads/{v16,v17,v18} — all sunset — while the cell's config.py declares SUPPORTED = v22..v25 and a v23 default. If that cell ever routes through the library rather than REST, the pin cannot serve the version it claims. For the GAQL cell's owner (CC-2 / WO-45 lane).


WO-25 — Data Manager connector request contract — fixed

Files: services/service-data-manager-connector/main.py; services/service-data-manager-connector/schemas/data_manager_v1/*.json (7 schema fixtures); services/service-data-manager-connector/tests/{conftest.py,test_wo25_data_manager_contract.py,fixtures/golden_ingest_request.json,fixtures/validate_only_diagnostics_response.json}; miz-oki-adk-agents/kg-canonical-ingest/mappers/google_ads.py; miz-oki-adk-agents/kg-canonical-ingest/tests/test_wo25_google_ads_mapper.py.

Step 0 on 1f601d171 — reproduced, four red tests (first run: 4 failed): - test_wo25_step0_destination_carries_product_destination_id — Destination had no productDestinationId (schema: required). - test_wo25_step0_conversion_action_routes_each_event_to_its_destination — conversion_action accepted by the model and never consumed; three events on two actions went to one destination. - test_wo25_step0_conversion_value_is_a_number_with_sibling_currency — conversionValue was {value, currencyCode} (schema: number, dependentRequired: currency). - test_wo25_step0_hashed_user_data_declares_its_encoding — hex digests sent with no request-level encoding.

The schemas (IngestEventsRequest, Event, Destination, ProductAccount, UserData, Consent, IngestEventsResponse) were transcribed from the official Data Manager API v1 REST reference into JSON Schema 2020-12 fixtures; nothing is fetched at test time, and test_wo25_schema_fixtures_reject_the_old_shape guards that each fixture actually catches its counterexample.

Fix (same connector — no second one built). - build_ingest_request(): one Destination per distinct conversion action, in first-seen order, reference: ca-<id>, operatingAccount, and the required productDestinationId (conversion_action_id() accepts customers/<cid>/conversionActions/<id> or a bare id; anything else → 422 — an event that cannot be routed is never uploaded to a default bucket). Optional login_account → loginAccount for manager-account uploads. - Every Event carries destinationReferences: [ca-<id>] (multi-action routing), conversionValue as a number with sibling currency, a validated eventSource, and an RFC 3339-validated eventTimestamp. - userData: email trimmed + lower-cased, gmail/googlemail dots removed (the spec's rules — the previous +tag stripping is removed because it produced a digest Google's side cannot match); phone E.164; SHA-256; request declares encoding: HEX. Test asserts no raw PII substring leaves the service. - validate-only plumbed and diagnostics surfaced: response is {status, validate_only, applied, uploaded, diagnostics} with diagnostics = {request_id, accepted, errors[{reason, field, description}], event_count, destination_count, destinations[{reference, productDestinationId, event_count}]}; a rejected validate-only run returns 422 with the rows (google.rpc.Status ErrorInfo + BadRequest.fieldViolations parsed), a rejected live upload keeps the provider's 4xx and maps 5xx → 502. The audit row now records applied, request_id, destination_count, error_count. The consent gate (422 before anything is sent) is unchanged and re-tested. - kg-canonical-ingest/mappers/google_ads.py: reads the Data Manager Event shape (eventTimestamp, numeric conversionValue + currency, transactionId, destinationReferences/productDestinationId, userData.userIdentifiers), still reads the legacy shape and the pre-fix object-valued conversionValue, and no longer writes an order id or gclid into object.conversion_action (the old fallback did, so graph queries by conversion action matched order ids).

Acceptance tests. services/service-data-manager-connector/tests/test_wo25_data_manager_contract.py — 14 passed (the four Step-0 tests, ..._email_and_phone_are_normalized_per_the_data_manager_spec, ..._conversion_action_accepts_resource_name_or_bare_id, ..._golden_request_matches_the_committed_fixture, ..._login_account_is_passed_when_a_manager_account_uploads, ..._validate_only_is_plumbed_and_diagnostics_are_surfaced, ..._validate_only_rejection_surfaces_the_recorded_diagnostics, ..._live_upload_is_not_validate_only_and_reports_applied, ..._consent_gate_still_refuses_before_anything_is_sent, ..._a_raw_email_never_leaves_the_service, ..._schema_fixtures_reject_the_old_shape). miz-oki-adk-agents/kg-canonical-ingest/tests/test_wo25_google_ads_mapper.py — 4 passed (measured 4 failed on a throwaway worktree at origin/main); tests/test_schema.py 12 passed. tests/test_dao.py fails to collect on main already (No module named 'kg_canonical_ingest' — the package directory is hyphenated); pre-existing, not touched.

Golden fixture: tests/fixtures/golden_ingest_request.json — two events on two conversion actions (the audit's $40 and $80 values), both hashed identifiers, encoding: HEX, validateOnly: true. Recorded diagnostics fixture: tests/fixtures/validate_only_diagnostics_response.json in the documented google.rpc.Status shape. Both are SYNTHETIC and say so in a _provenance field / docstring; no Data Manager account was used.

UI consumer check: miz-oki-command-center-ui/lib/bff/adapters/data-manager-connector.ts types the response as Record<string, unknown> and reads no field; the response-shape change breaks no typed caller. docs/frontend/FRONTEND_SERVICE_CONTRACT_MAP.md line 126 still describes the route correctly (validate_only default TRUE, 422 without consent).

Deferred / OPS checklist (not performed): 1. With ADC for a principal holding the Data Manager scope, POST the golden request with validate_only: true to a test Google Ads account's conversion actions; expect 200 {requestId} and diagnostics.accepted: true. Then send one deliberately malformed event and capture the 400 body; replace validate_only_diagnostics_response.json with the redacted capture and drop its _provenance SYNTHETIC label in the same commit. 2. services/service-data-manager-connector has no CI deploy workflow (built only by ops/remediation/deploy_all.sh) and its tests run in no workflow. Adding either is a protected-path change → review PR, not this branch.


WO-21 — Boss perimeter — skipped here (built elsewhere)

Built on claude/boss-chat-auth-ext-0915 (require_chat_caller, miz-oki-adk-agents/boss/boss_agent_core.py). This branch does not touch miz-oki-adk-agents/boss/** or miz-oki-adk-agents/app/main.py. The route inventory, the deploy-config review PR and the read-only IAM invoker check belong to that lane.


Gates run

Gate Result
pytest tests/remediation/test_wo24_google_ads_api_version.py tests/remediation/test_execution_adapters.py tests/governance/test_google_ads_api_version_sunset.py 193 passed
pytest services/service-data-manager-connector/tests 14 passed
pytest miz-oki-adk-agents/kg-canonical-ingest/tests/test_wo25_google_ads_mapper.py miz-oki-adk-agents/kg-canonical-ingest/tests/test_schema.py 16 passed (test_dao.py: pre-existing collection error on main)
pytest tests/test_client_library_pin_ratchet.py tests/reports/test_evidence_manifest.py (+ sunset guard) 21 passed, 11 subtests
python3 scripts/gate_leak_scan.py --check 0 new, 0 grown, 0 stale
python3 scripts/claude_memory.py check --strict structurally valid
pytest miz-oki-adk-agents/boss (prompt gate, WO-21 lane) not runnable in this sandbox — 4 collection errors on the base itself (aiohttp, vertexai, shared.model_registry missing; BeliefState import). boss/** is byte-identical to origin/main on this branch.
Live validate-only runs (both WOs) skipped — need credentials and a test account; OPS checklist above

Deploy fan-out (python3 .github/scripts/deploy_router.py --base origin/main --head HEAD): 22 changed files → 1 workflow: deploy-service-action-runner.yml (matched services/service-action-runner/execution_adapters/google_ads.py). service-data-manager-connector and kg-canonical-ingest have no push-triggered deploy workflow, so those changes deploy nothing on merge. Merging this PR therefore deploys 1 service (action runner; adapters remain EXECUTION_ADAPTERS_ENABLED=false, and the workflow's posture check fails the deploy if production reports otherwise).

Files changed (git diff --stat origin/main HEAD): 22 files — services/service-action-runner/execution_adapters/google_ads.py, services/service-data-manager-connector/main.py, miz-oki-adk-agents/kg-canonical-ingest/mappers/google_ads.py, plus the tests, schema and fixture files listed per WO above, and this report.

Commits on the branch (base 1f601d171): - ff8f2f0fb WO-24: Google Ads adapter — default v25 (newest library-supported), committed sunset list, refusal of sunset overrides, recorded-fixture tests for all five actuators - d35168dc9 WO-25: Data Manager connector — conform IngestEvents request to the v1 Event/Destination schema, multi-action routing, numeric conversionValue, declared HEX encoding, validate-only diagnostics

Exact commands that prove the pack:

python3 -m pytest tests/remediation/test_wo24_google_ads_api_version.py tests/remediation/test_execution_adapters.py tests/governance/test_google_ads_api_version_sunset.py -q
python3 -m pytest services/service-data-manager-connector/tests -q
python3 -m pytest miz-oki-adk-agents/kg-canonical-ingest/tests/test_wo25_google_ads_mapper.py miz-oki-adk-agents/kg-canonical-ingest/tests/test_schema.py -q
python3 .github/scripts/deploy_router.py --base origin/main --head HEAD
← All docsView source on GitHub →