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):
- the default was not the newest version both in Google's release notes and
supported by the client library (
v23, notv25); - the adapter carried no committed sunset list, and an override
GOOGLE_ADS_API_VERSION=v21was sent to the wire (Google would hard-fail it, with the provider's error, not ours); - only the campaign-budget actuator had mutation + read-back tests; the other four actuators were untested.
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