Skip to content

PS4 — Mock Proxy & Test Doubles

The Labs agent stack and the consumer mobile app exercise two test surfaces that today have no deterministic substitute for live upstream services:

Surface A: CCS enrichers calling 16 hosted Fetch upstreams (full table in §3.1). consumer-context-service (CCS) aggregates FPS, FIDORA, Button + Button Partners, Retailer, eReceipt (provider + handbook), Offer Guardian (SDK), NELI, PointPass, Offer Search, FIDO Search, LIDAR, Purchase History, a generic web fetcher, the BrightData SERP proxy, plus (during the migration) Neo4j and (future) Neptune. Each upstream is owned by a different team, lives in a different VPC, has its own availability profile, and surfaces its own dialect of failure.

Surface B: consumer-agent calling CCS via PS2 BaseTool connectors. Per PS2 (consumer-agent#286), every CCS-backed agent capability is wrapped in a Python LangChain BaseTool subclass that unwraps PS6’s EnricherResponse[T] envelope at the wrapper boundary. PS2 §6 (PS4 row) explicitly delegates to PS4: “PS4 inherits PS2’s connector primitive — mocks must conform to the same BaseTool shape. PS2 §9.4 contract test relies on PS4-provided mocks.”

Three workloads hit one or both surfaces and need deterministic substitutes:

  1. CI integration tests (Surface A). Today CCS integration tests use docker-compose.yml to spin up real Neo4j + Python sidecars, but the hosted upstreams either fall through to staging URLs (non-deterministic, brittle when staging is degraded, cross-team blast radius) or get stubbed per-test with ad-hoc httptest.Servers that duplicate setup code across every test file.
  2. Agent eval runs (Surfaces A + B; PC5 — PLT-683). Eval suites grade the consumer-agent’s behavior over a fixed scenario set. When evals hit real upstreams or real CCS, scoring is non-reproducible: an upstream returning a different recommendation on Tuesday vs Wednesday makes regression detection impossible.
  3. Offline / local development (Surfaces A + B). Engineers adding a new enricher or vertical (S1b Play, S1d eReceipts, S1e Offer Details, S1f Restaurant, S1g Retailer) need to iterate without setting up 12 staging credentials or being blocked when staging is broken.

Without this spec, three things go wrong:

  1. Non-deterministic CI. Test flakes track staging health, not code correctness. Engineers learn to ignore CI failures.
  2. Eval scores drift. PC5 grades the agent against fixtures the agent has never seen; behavior changes can’t be attributed to code changes vs upstream changes.
  3. Cross-team blast radius. When FIDORA or Button is degraded in staging, CCS tests fail, CCS deploys block, and the Labs program is gated on an unrelated team’s outage.

This spec establishes:

  • Layer 1: a Go http.RoundTripper substitution mechanism at CCS’s shared HTTP transport, covering hosted upstream services.
  • Layer 1b: an interface-level substitution shape for SDK-wrapped upstreams (Offer Guardian today; template for future SDK upstreams).
  • Layer 2: a Python BaseTool mock convention in consumer-agent, conforming to PS2’s connector interface.
  • A deterministic JSON fixture format keyed by normalized request shape, shared across the three layers (different _key contents per flavor, same file structure).
  • Per-upstream normalizers and redactors as named, replaceable units.
  • A capture mode that records real upstream responses from non-prod sources and applies redaction before persisting.
  • Eval-mode isolation at both layers — env-var-gated, fail-closed against real-upstream traffic.
  • A minimal CLI for fixture authoring: list / validate / capture.
  • A drift-detection mechanism — per-fixture captured_at, CI age warning at 90 days, weekly cron diff against stage.

From the Platform Spec Lab Confluence (Services row PS4): “PS4 — Mock Proxy & Test Doubles (CCS-focused). A CCS-side mock proxy and test doubles so eval runs, integration tests, and offline development don’t have to hit real upstream services. Provides deterministic fixtures for the BFF enrichment path.”

PS4 creates: the deterministic test-substitution mechanism (L1 Go RoundTripper / L1b SDK-interface / L2 Python BaseTool), the shared JSON fixture format, the per-upstream normalizer + redactor framework, capture-mode with allowlist + quarantine + PR-label gating, eval-mode isolation that’s fail-closed by build-tag in Go and by per-call guard in Python, the drift-detection cron + age-warning lint, and the per-vertical onboarding pattern that lets a new vertical author one normalizer + one redactor and inherit all of the above. The capabilities_section in frontmatter is "derived" per Confluence’s note for this row — this spec is the canonical reference for the Labs stack. The PLT-688 ticket’s Cross-section dependencies (PS2 + PS6) explicitly bundles both surfaces (CCS upstreams AND CCS-as-seen-from-agent) under PS4.

CCS upstream surface (verified against internal/clients/ directory listing as of branch HEAD): three distinct staging URL conventions; the full list of clients:

Client fileUpstreamPS4 coverage
fps.goFIDO Product ServiceL1 — REST JSON, stable shape
fidora.goFIDORA (assortment)L1 — REST JSON
button.goButton merchantsL1 — full-cache load on startup; normalizer must collapse the cache-load batch to a stable key
button_partners.goButton partner dataL1
retailer.goRetailer ServiceL1
ereceipt.goeReceipt provider + handbookL1 — dual-provider parallel check; both endpoints fixtured independently
offer_guardian.goOffer Guardian SDKL1b — SDK-level wrapped behind a CCS interface; method-call-shaped fixtures
fido_index.goOG reverse indexL1 (HTTP refresh path)
neli.goNELI eligibilityL1
pointpass.goPointPass (L5 features)L1
purchase_history.goPurchase HistoryL1
offer_search.goOffer Search (ML)L1
fido_search.goFIDO Search (ML)L1
lidar.goLIDAR (location offers)L1
web.goGeneric web page fetcherL1 — high variability, ideal RoundTripper substitution candidate
brightdata.goBrightData SERP proxyL1custom-transport case: this client builds its own *http.Transport with proxy + InsecureSkipVerify for the BrightData SERP proxy. PS4 still hooks via the MakeHTTPClient(timeout, customTransport) seam — see §5.3.
neo4j.goNeo4j (graph reads)Special-cased — out of PS4. Bolt protocol; substituted via real Neo4j in docker-compose.yml + make seed-neo4j. Stays as-is for the duration of the Neo4j→Neptune migration.
(future) NeptuneNeptune DB (openCypher over HTTPS+SigV4)L1 — RoundTripper substitution works once a CCS Neptune client lands; mechanism designed now, no fixtures authored until the client lands. The reference client in consumer-graph-capacity-experiments/internal/neptune/client.go already exposes Config.HTTPClient injection.
brand.go, category.go, fuzzy_match.goPython sidecarsOut of scope — deprecated. These sidecars are being removed from CCS; PS4 does not invest in covering them.

Shared HTTP plumbing: internal/clients/http.go today provides doJSONGet / doJSONPost / isNotFoundError — pure helper functions that take a *http.Client from the caller. Each client in internal/clients/ constructs its own *http.Client independently (e.g., fidora.go:44, web.go:39, brightdata.go:94). Phase 1 (§10.1) adds a shared MakeHTTPClient(timeout, base http.RoundTripper) builder in http.go that all clients adopt; the builder is the L1 PS4 insertion point (see §5.3). The doJSON* helpers are unchanged.

Today’s substitution patterns (what PS4 replaces):

  • Local Neo4j via docker-compose.yml + make seed-neo4j — works, stays as-is.
  • Hosted upstreams — no consistent substitution. Tests either point at staging or stand up per-test httptest.Servers. PS4 replaces this with L1.
  • Python sidecars — deprecated; being removed.

3.2 consumer-agent surface (Layer 2 substrate)

Section titled “3.2 consumer-agent surface (Layer 2 substrate)”

Per PS2 (consumer-agent#286):

  • Every CCS-backed agent capability is a Python LangChain BaseTool subclass with a Pydantic args_schema, a _run method (and optionally _arun), and a name/description.
  • Path 1 (CCS-backed) connectors call a CCS endpoint and unwrap PS6’s EnricherResponse[T] envelope at the wrapper boundary (PS2 §5.4 / FR-6); the BaseTool returns the unwrapped payload.
  • PS2 mandates that PS4 mocks “conform to the same BaseTool shape” (PS2 §6, §9.4).

L2 mocks live in consumer-agent/<connectors>/mocks/ (next to the real connectors). The mock is a BaseTool subclass with matching name and args_schema; its _run looks up a fixture by (tool_name, canonical-args-hash) and returns the fixture’s response.payload directly (bypassing the wrapper’s HTTP+unwrap path, since the mock IS the wrapper).

DecisionRationaleSource
PS4 covers both Surface A (Go RoundTripper) and Surface B (Python BaseTool mocks)PLT-688 Cross-section dependencies bundles PS2 + PS6; PS2 §6 explicitly delegates BaseTool mocks to PS4PLT-688 + PS2 §6, §9.4
Substitution at the transport boundary (L1) or via subclass (L2), not per-test branching inside production logicProduction code path stays identical between test and prod; one substitution surface per layerFrontmatter constraints
Fixtures live in source controlReproducibility — a test that fails in CI must fail on the engineer’s laptop with the same dataPlatform Spec Lab convention
Capture mode refuses prod-shaped hostnames (allowlist)Defense-in-depth against PII leak into fixture filesBrainstorm 2026-05-21 OQ-4
Neo4j stays as-is (real instance + seed)Bolt protocol doesn’t fit RoundTripper; real-instance-with-seed is the right shape for graph traversal testsBrainstorm OQ-3; carries through migration
Python sidecars are deprecated → out of PS4 scopeThey’re being removed from CCSBrainstorm OQ-3
Eval mode is env-var-gated and fails closed on real-upstream callsPC5 grading is unreproducible if upstreams are live; soft enforcement is not enforcementPLT-688 ticket; brainstorm
TermDefinition
Layer 1 (L1)Go HTTP substitution: a custom http.RoundTripper installed on every client’s *http.Client via the shared clients.MakeHTTPClient builder; substitutes for raw-HTTP upstreams
Layer 1b (L1b)Go SDK substitution: a CCS-defined interface (e.g., OfferGuardianClient) whose production impl wraps an SDK and whose mock impl returns fixtures by method-call key
Layer 2 (L2)Python BaseTool substitution: a mock subclass of a PS2 BaseTool connector, with matching name/args_schema, whose _run returns fixtures
FixtureA committed, deterministic JSON file with _meta + _key + response sections
Fixture keySHA-256 hash of the canonical _key object after per-upstream/per-tool normalization
NormalizerPer-upstream/per-tool code that strips non-deterministic fields (timestamps, request IDs, nonces, auth tokens) from a request before key derivation
RedactorPer-upstream code that strips PII / secrets from a captured response before the fixture is persisted
Replay modeOperating mode where PS4 serves fixtures and refuses to call real upstreams
Capture modeOperating mode where PS4 records real upstream responses (from allowed sources only) and writes redacted fixture candidates
Eval modeA stricter form of replay mode for PC5 eval runs: fixture_not_found is fatal, real-upstream fallthrough is impossible, and a metric increments on any blocked call
Capture-source allowlistCompiled-in list of hostnames PS4 capture mode is permitted to record from (stage, local, synthetic-fixture environments)
ps4-fixture-review labelGitHub PR label required on any PR introducing a capture-mode-generated fixture; CI lint fails without it
UpstreamRegistrationPer-upstream Go struct (defined in §5.3) that bundles hostname, capture-allowlist flag, normalizer ref, redactor ref, maintainer team, and known-fields YAML pointer; lives in internal/testing/ps4/upstreams/<upstream>.go and registers itself into the ps4.Registry via init()
RegistryProcess-init-built map of upstream-name → UpstreamRegistration (and reverse hostname → upstream-name) that the L1 transport, capture-source allowlist, drift cron, and ps4 validate all read; replaces the central upstreams.go / allowlist.go / MAINTAINERS.yml files that would otherwise be merge-conflict surfaces

Requirements use unordered markdown bullets; the labels (FR-L1-N, FR-L1b-N, FR-L2-N, FR-X-N) are the authoritative identifiers.

Layer 1 (Go HTTP RoundTripper substitution):

  • FR-L1-1: PS4 MUST install an http.RoundTripper on every *http.Client used by clients in internal/clients/. Today each client constructs its own *http.Client independently (e.g., fidora.go, web.go, brightdata.go); this spec mandates that ALL such constructions route through a shared builder clients.MakeHTTPClient(timeout time.Duration, base http.RoundTripper) *http.Client introduced as part of Phase 1 (§10.1). The second argument is the custom transport the client wants installed beneath the PS4 hook (e.g., BrightData’s proxy transport); callers without a custom transport pass nil to get http.DefaultTransport. The builder is the single seam PS4 hooks: in production builds it returns a plain client; in test builds (-tags ps4test) it wraps the transport per PS4_MODE. Enablement is via env var PS4_MODE ∈ {off, replay, capture, eval}; default off. The substitution code is compiled out of the production binary via build tag //go:build ps4test (see FR-X-6) — PS4_MODE is a no-op in prod builds even if set. A CI lint MUST fail any direct &http.Client{…} literal in internal/clients/ outside of clients.MakeHTTPClient. Pre-existing custom-transport clients (e.g., brightdata.go’s proxy transport) MUST take a RoundTripper argument and wrap it through MakeHTTPClient’s seam — they cannot bypass PS4 by constructing their transport stack independently.

  • FR-L1-2: For each upstream listed in §3.1 with L1 coverage, PS4 MUST have a registered normalizer that produces a canonical _key object from a request (method, path, query, body, headers). The normalizer MUST strip non-deterministic fields (timestamps, request IDs, nonces, auth tokens) AND apply upstream-specific rules — e.g., Neptune parameters field is a JSON-encoded STRING and nil is normalized to "{}". The fixture key is SHA-256(canonical JSON of normalized _key).

  • FR-L1-3: In replay mode AND eval mode, the RoundTripper MUST look up the fixture by key and return its response.{status, headers, body} as an *http.Response. On miss OR on a request whose hostname is not registered in the upstream-map, it MUST return a structured error wrapping fixture_not_found (with the requested key, upstream, method, path), AND MUST NOT call the real upstream. There is NO replay-mode-fallthrough-to-base — that path only exists in off mode.

  • FR-L1-4: In capture mode, the RoundTripper MUST:

    1. Refuse to record if the request hostname is not on the compiled-in capture-source allowlist (see definition below) — refusal returns a structured capture_source_not_allowed error, no fixture written.
    2. Refuse to record if the upstream has no registered redactor in redactors[<upstream>] (fail-closed default) — returns redactor_not_registered. This guarantees no upstream can be captured before its redactor + redactor unit tests have landed.
    3. Disable HTTP redirect-following on the http.Client used in capture mode (set Client.CheckRedirect to return http.ErrUseLastResponse — redirects are a http.Client concern, not http.Transport) — prevents an allowlisted host from redirecting to a non-allowlisted one mid-call.
    4. Call the real upstream, apply the registered redactor to the response body AND the response headers. The response-header rule is an authoritative allowlist: keep ONLY Content-Type, Content-Encoding, Content-Length; drop everything else regardless of header name. Common attack-surface headers such as Set-Cookie, Authorization, WWW-Authenticate, Proxy-Authenticate, and any X-Amz-* are dropped by this rule — they are listed in the redactor implementation as illustrative test cases, not as an additional denylist.
    5. Stage the fixture into a quarantine directory (testdata/fixtures.pending/<upstream>/) and run ps4 validate against it before promoting to testdata/fixtures/<upstream>/. Validation failure → fixture stays quarantined.
    6. Write _meta.captured_at, _meta.captured_from (the req.URL.Hostname() (note: this is the request authority hostname, NOT post-DNS — DNS-spoof defense is out of scope for PS4; the threat model is operator error, not adversarial DNS), NOT a header-derived hostname), _meta.captured_by, _meta.redacted_fields.

    Capture-source allowlist semantics: exact-string match on req.URL.Hostname() (hostname only — port is NOT part of the match; default ports for HTTPS are the only ports CCS upstreams use, and locking to a specific port adds no security but breaks staging). No globs, no suffix match, no header-derived hosts. The allowlist is built at package-init from per-upstream registration files (see §5.3) — there is no central allowlist.go to collide on; each upstream’s entry lives in its own file.

  • FR-L1-5: In eval mode, the RoundTripper MUST behave like replay mode AND MUST emit a metric ps4.eval_mode.real_call_blocked_total{layer="L1", upstream=…} on any fixture_not_found outcome. The expected steady state in a healthy eval run is 0 (NFR-7).

  • FR-L1-6: When PS4_MODE=eval, the underlying HTTP transport MUST be a sentinel that returns an error before any DNS resolution or socket open. Defense-in-depth: even if FR-L1-3’s lookup logic has a bug, the network is unreachable.

Layer 1b (Go SDK interface substitution):

  • FR-L1b-1: For each SDK-wrapped upstream (Offer Guardian today), CCS MUST expose a Go interface in internal/clients/<upstream>.go whose production impl wraps the SDK. PS4 MUST provide a mock impl satisfying the same interface that returns fixtures keyed by (client_name, method_name, canonical-args-hash). v1 covers OfferGuardianClient; the convention is documented for future SDK upstreams.
  • FR-L1b-2: Method-call fixture files use the same JSON shape as L1; only the _key shape differs ({client_name, method_name, args_canonical} instead of {upstream, method, path, query, body}). FR-L1-4’s redactor-required and quarantine rules apply identically when generating L1b fixtures via capture.

Layer 2 (Python BaseTool subclass substitution):

  • FR-L2-1: For each PS2 BaseTool connector in consumer-agent that wraps a CCS endpoint, PS4 MUST provide a mock subclass with the same name, the same args_schema, and a _run/_arun that returns a fixture by (tool_name, canonical-pydantic-args-hash).
  • FR-L2-2: L2 fixtures emit the unwrapped payload (the value the real BaseTool would return after envelope-unwrap), not the raw PS6 EnricherResponse[T] envelope. The mock replaces the wrapper; there is no envelope to unwrap.
  • FR-L2-3: A conformance test in consumer-agent/<connectors>/mocks/ MUST assert that each L2 mock and its real connector share an identical args_schema (Pydantic model equality). The test fails CI if a real connector adds/changes a field without the mock updating.
  • FR-L2-4: L2 supports PS4_MODE=eval via a guard in the PS2 connector base class — when eval mode is set, instantiating a real (non-mock) BaseTool raises at construction. PS4 conformance asserts the guard is wired. Metric ps4.eval_mode.real_call_blocked_total{layer="L2", connector=…} increments on blocked instantiation.
  • FR-L2-5: The L2 eval-mode guard MUST re-check on every _run/_arun invocation (not only at __init__). Goal: a test that mutates os.environ["PS4_MODE"] after constructing a real connector cannot escape the guard at call time. Implementation mechanics (process-start snapshot, OR-semantics, reload-resistance) are in §5.6.
  • FR-L2-6: L2 fixtures in consumer-agent/<connectors>/mocks/fixtures/ MUST be governed by the same controls as L1 fixtures: same _meta schema (FR-X-2), same ps4-fixture-review PR label gate, same CODEOWNERS enforcement (FR-X-4). L2 has no capture mode (fixtures are hand-authored or derived from L1-captured payloads), so the redactor-required check from FR-L1-4 does not apply directly. Instead, consumer-agent CI MUST run a Python validator that scans every L2 fixture’s response.payload for the full NFR-5 PII pattern enumeration (emails, phones, addresses, names, geolocation, partial PANs, free-text fields, JWT-shaped tokens, signed-URL params, internal hostnames, stack frames); any match fails CI. The validator is either a Python port of ps4 validate or a wrapped invocation of the Go binary.

Cross-layer:

  • FR-X-1: PS4 MUST NOT alter the production code path of the enricher or BaseTool connector under test. L1 substitution is at the HTTP transport; L1b is via dependency injection of the interface; L2 is via subclass selection at construction time.
  • FR-X-2: All fixture files MUST carry _meta.captured_at (RFC 3339 UTC), _meta.captured_from (hostname, or "hand-authored"), _meta.captured_by (committer), _meta.ps4_version (currently "1"), and _meta.redacted_fields (list of dotted-path field names redacted). Fixtures hand-edited after initial capture MUST update _meta.captured_at to the edit time AND append a _meta.hand_modified boolean=true; the cron diff (§5.8) MUST exclude fixtures with _meta.hand_modified=true from drift checks.
  • FR-X-3: The ps4 CLI MUST provide three subcommands — list, validate, capture — and nothing else in v1. (See §5.7.)
  • FR-X-4: A CI lint check MUST fail any PR that adds OR modifies a file under testdata/fixtures/** (or the L2-fixture path in consumer-agent) without the ps4-fixture-review label on the PR. The trigger is the file path, not _meta.captured_from — this prevents bypass by editing an existing fixture or by flipping _meta.captured_from to "hand-authored". A CODEOWNERS entry MUST require an additional approval from a platform-security maintainer group for changes to ALL of: testdata/fixtures/**, the L2-fixture path in consumer-agent, internal/testing/ps4/known_fields/**, internal/testing/ps4/upstreams/** (the per-upstream registration files that carry the allowlist entry, normalizer/redactor refs, and maintainer team — see §5.3), internal/testing/ps4/redactors/**, and .github/workflows/fixture-refresh.yml.
  • FR-X-5: A CI step MUST emit a non-blocking warning (printed to PR summary) for each fixture older than 90 days at the time of the PR build. Warnings list the fixture path + age in days.
  • FR-X-6: The Go PS4 substitution code (L1 RoundTripper installer, L1b mock impls) MUST be gated behind build tag //go:build ps4test so production Go builds CANNOT enable substitution under any environment configuration — PS4_MODE is not read by production Go code. For the Python L2 path, the guard in PS2’s BaseTool base class DOES read PS4_MODE at runtime — this is acceptable because (a) the read is a no-op when PS4_MODE is unset (the production state), and (b) the guard’s only behavior is to raise EvalModeRealConnectorBlocked when PS4_MODE=eval, which is never set in prod. The asymmetry between languages is intentional: Go has compile-time tags; Python does not, and a runtime no-op guard is the equivalent safety property.
  • FR-X-7: Tests running under PS4_MODE=capture MUST NOT emit unredacted upstream response bodies or response headers to stdout/stderr. PS4 itself MUST NOT log raw response bodies or headers anywhere in its code path; any debug logging inside internal/testing/ps4/ MUST route through a centralized helper that applies the per-upstream redactor before writing. Tests that need to inspect response bodies for assertions MUST use the fixture’s _meta.redacted_fields view, not the raw response. A trivial CI grep MUST flag any t.Log/t.Logf/fmt.Println literal of resp.Body / resp.Header in internal/clients/*_test.go and internal/testing/ps4/**; this is a coarse static check, not an attempt to detect arbitrary dataflow.
  • NFR-1 (L1 lookup overhead): PS4 RoundTripper lookup MUST NOT dominate test runtime. Fixtures SHOULD be cached in-process after first read so subsequent lookups in a single test process are constant-time map operations; cold load latency follows the test framework’s filesystem characteristics.
  • NFR-2 (Fixture file size cap): No single fixture file may exceed 1 MB. Larger responses MUST be split or sampled. Cap enforced by ps4 validate.
  • NFR-3 (Determinism): Same input bytes → same fixture key → same response bytes, byte-for-byte, across machines and PS4 versions within a major version. _meta.ps4_version is bumped only for breaking changes to the fixture file shape, the _key canonicalization rules, or the hashing algorithm — i.e., changes that invalidate existing fixtures. Adding new optional _meta fields, new normalizer rules for new upstreams, or new layers does NOT bump the version. Major version bumps require re-keying (CLI tooling for this is a v2 concern).
  • NFR-4 (Coverage): Every CCS client in §3.1 marked L1 or L1b MUST have a normalizer + redactor at the close of PS4 Phase 2 rollout (see §10.1). Coverage is enforced by a CI unit test that fails if a registered client lacks a registered normalizer.
  • NFR-5 (Redactor PII coverage): Each per-upstream redactor MUST have unit tests asserting that ALL of the following PII / secret categories are scrubbed from a synthetic response containing them. The base test fixture lives in internal/testing/ps4/testdata/pii_synthetic.json and every upstream’s redactor test consumes it.
    • Identifiers: emails, phone numbers, street addresses, customer first/last names, FET user IDs, internal account IDs, AWS account IDs and ARNs.
    • Financial: full or partial PANs, last-4 digits in eReceipt items, transaction IDs that resolve to a single user.
    • Geolocation: lat/long pairs, ZIP codes paired with other identifiers, IP addresses.
    • Free-text: shopping-list strings, search-query strings, receipt free-text fields (these can contain raw user input and are PII by default — high-PII upstreams (LIDAR, PurchaseHistory, eReceipt) use an allowlist-of-fields-retained redactor rather than a denylist).
    • Secrets: JWT-shaped tokens, signed-URL query parameters (X-Amz-Signature, Signature=, token=, key=), Set-Cookie header values, Button merchant API keys, Offer Guardian bearer tokens.
    • Operational leakage: internal hostnames, internal hostname patterns in error bodies, stack frames containing internal absolute paths.
  • NFR-6 (PS2 inheritance): L2 mock args_schema MUST equal the real connector’s args_schema (Pydantic model equality) at all times. Drift is a CI failure (see FR-L2-3).
  • NFR-7 (Eval mode invariance): A single test run in PS4_MODE=eval MUST NOT produce any ps4.eval_mode.real_call_blocked_total increments under normal operation. Any increment is treated as a misconfiguration regression.
  • AC-1 (L1 replay hit): Given a CCS integration test against the FIDORA client with PS4_MODE=replay, when a matching fixture exists, then the client receives the fixture response and lsof / netstat observation confirms no socket left the process.
  • AC-2 (L1 replay miss): Given PS4_MODE=replay and a request with no matching fixture, when the client calls the upstream, then the response is a structured fixture_not_found error containing {upstream, method, path, computed_key}, AND no real-network call is made.
  • AC-3 (L1 capture redaction): Given PS4_MODE=capture against an allowlisted stage hostname, when the upstream response body and headers contain any pattern from NFR-5’s enumerated categories, then the persisted fixture has those fields replaced with "<redacted>" and _meta.redacted_fields lists their dotted paths. Response headers outside the documented allowlist (Content-Type, Content-Encoding, Content-Length) are dropped entirely, not redacted.
  • AC-4 (L1 capture allowlist refusal): Given PS4_MODE=capture and a target whose req.URL.Hostname() does NOT exactly match an entry compiled in from the per-upstream registration files at internal/testing/ps4/upstreams/** (no glob, no suffix match, no header-derived host), when the call is initiated, then PS4 returns capture_source_not_allowed and writes no file. The denial MUST also fire if a 3xx redirect would land on a non-allowlisted host (redirects are disabled at the http.Client.CheckRedirect level in capture mode).
  • AC-4b (Missing redactor refusal): Given PS4_MODE=capture and an upstream with no registered redactor (i.e., not yet onboarded per NFR-4), when capture is initiated, then PS4 returns redactor_not_registered and writes no fixture.
  • AC-5 (Eval mode L1): Given PS4_MODE=eval and any code path that would reach the real network, when the call attempts to proceed, then the RoundTripper returns fixture_not_found (no fallthrough is attempted; FR-L1-6’s sentinel transport also blocks the call beneath the lookup), and ps4.eval_mode.real_call_blocked_total{layer="L1"} is 0 at end of a healthy run.
  • AC-5b (Eval mode unknown hostname): Given PS4_MODE=eval and a request to a hostname not registered in the upstream-map, when the RoundTripper is reached, then the result is fixture_not_found with upstream="<unknown>". This is the test FR-L1-6 hangs on.
  • AC-5c (Build-tag isolation): Given a production binary built WITHOUT -tags ps4test, when any test attempts to set PS4_MODE=eval (or any other value), then the env var has no effect on the binary’s transport — the substitution code is not compiled in.
  • AC-6 (L1b SDK substitution): Given a CCS test exercising the Offer Guardian enrichment path with PS4_MODE=replay, when the production code calls OfferGuardianClient.GetOffer(ctx, offerID), then the mock impl is wired (not the SDK-backed impl), and a fixture keyed on (offer_guardian, GetOffer, args_hash) is returned.
  • AC-7 (L2 mock conformance): Given a real PS2 connector FetchProductContextTool in consumer-agent with an args_schema = ProductContextArgs(BaseModel), when the conformance test runs, then the L2 mock subclass has args_schema == ProductContextArgs (identity check passes); changing a field in the real schema without updating the mock fails CI.
  • AC-8 (L2 fixture return shape): Given an L2 mock for a Path-1 (CCS-backed) BaseTool and PS4_MODE=replay, when _run(args) is called, then the return value is the unwrapped payload (the same shape the real BaseTool would return after PS6 envelope-unwrap), keyed by (tool_name, args_canonical_hash).
  • AC-9 (Eval mode L2 — instantiation): Given PS4_MODE=eval and any test that tries to instantiate a real (non-mock) BaseTool connector, when construction is attempted, then the PS2 guard raises EvalModeRealConnectorBlocked, and ps4.eval_mode.real_call_blocked_total{layer="L2"} increments.
  • AC-9b (Eval mode L2 — invocation re-check): Given a real (non-mock) BaseTool successfully instantiated under PS4_MODE=replay, when PS4_MODE is mutated to eval and _run is called, then the per-call re-check (FR-L2-5) raises EvalModeRealConnectorBlocked and the metric increments. The guard cannot be escaped by env-var mutation post-init.
  • AC-10 (CLI: list — L1 scope): Given fixtures exist under testdata/fixtures/fidora/, when ps4 list --upstream fidora runs from the CCS repo, then it prints fixture paths + ages for L1 + L1b fixtures only. L2 fixtures live in a separate repo; v1 ps4 list does NOT enumerate them. To list L2 fixtures, an engineer runs python -m connectors.mocks.list (a small Python equivalent) from the consumer-agent repo.
  • AC-11 (CLI: validate): Given a fixture file with missing _meta.captured_at, when ps4 validate path/to/fixture.json runs, then it exits non-zero with a clear error pointing to the missing field. Used by CI lint.
  • AC-12 (CLI: capture quarantine): Given ps4 capture --test TestFidoraEnrich --output testdata/fixtures/fidora/, when the test runs with PS4_MODE=capture against an allowlisted host, then per-upstream redactor runs on each captured response, fixtures are written FIRST to a sibling testdata/fixtures.pending/fidora/ quarantine dir, ps4 validate runs against the pending files, and only validated files are moved to --output. The ps4-fixture-review PR label is still required (FR-X-4).
  • AC-13 (PR label gate — add OR modify): Given a PR that adds OR modifies a file under testdata/fixtures/** (or the L2-fixture path in consumer-agent) and does NOT carry the ps4-fixture-review label, when CI lint runs, then the PR build fails with a clear pointer to the labeling requirement. The trigger is the file path, regardless of _meta.captured_from value.
  • AC-14 (Age warning): Given a PR build where any fixture’s _meta.captured_at is older than 90 days, when CI runs, then the build succeeds (warning is non-blocking) and the PR summary lists the stale fixtures + their ages.
  • AC-15 (Drift cron — fail-closed posting): Given the weekly fixture-refresh GHA cron runs against opted-in upstreams and a fixture diverges from the live stage response, when the diff is computed, then:
    1. The redactor MUST be applied to BOTH the committed fixture’s response AND the live captured response before diffing.
    2. The cron MUST consult internal/testing/ps4/known_fields/<upstream>.yml — a per-upstream allowlist of top-level field names already known to the redactor. If the live response contains any top-level field NOT in this allowlist, the cron MUST fail-closed: no public GitHub issue is opened. Instead, a private-channel maintainer notification is sent that MUST contain ONLY the new field name(s) and the upstream name — NEVER any field values or sample data. The full live response stays in a workflow artifact gated by repo-read permissions.
    3. Otherwise, on a non-empty diff, the cron opens a GitHub issue labeled ps4-drift containing ONLY the field-path list + size delta. The full post-redaction diff is uploaded as a private workflow artifact attached to the issue; the artifact is gated by repo-read permissions, not pasted into the issue body.
┌──────────────────────────── consumer-agent (Python) ────────────────────────────┐
│ │
│ PS2 BaseTool connector ──────────────────────────────────────────────┐ │
│ │ │ │
│ ├── PROD path: real `_run` issues HTTP to CCS, │ │
│ │ receives PS6 EnricherResponse[T] envelope, │ │
│ │ unwraps and returns payload │ │
│ │ │ │
│ └── TEST path: L2 mock subclass replaces the connector │ │
│ fixture lookup by (tool_name, args_hash) │ ◄── Layer 2
│ returns unwrapped payload directly │ │
│ │ │
│ Eval-mode registry guard (in PS2 BaseTool base class): │ │
│ PS4_MODE=eval && real-connector instantiation → raises │ │
└──────────────────────────────────────────────────────────────────────────────────┘
│ (in prod) HTTPS to CCS via PS6 envelope
┌─────────────────────────── consumer-context-service (Go) ───────────────────────┐
│ │
│ PS6 enricher (FullEnrich, GetProductContext, …) │
│ │ │
│ per-client *http.Client constructed via clients.MakeHTTPClient(timeout, base) │
│ │ (clients/http.go: build-tag-agnostic builder seam) │
│ │ (doJSONGet/doJSONPost helpers receive the built client) │
│ │ │
│ │ Transport = installPS4(base) → PS4 RoundTripper when -tags ps4test │
│ │ AND PS4_MODE != off; plain transport otherwise (prod). │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ internal/testing/ps4/ │ │
│ │ │ │
│ │ Transport wraps base http.Transport │ ◄── Layer 1 │
│ │ ├── replay: lookup → fixture │ │
│ │ ├── capture: real call → redact → write │ │
│ │ └── eval: lookup → fixture or fail-closed │ │
│ │ │ │
│ │ upstreams/<upstream>.go ← init()-registered to │ │
│ │ ps4.Registry (§5.3): │ │
│ │ { hostname, allowlist, │ │
│ │ normalizer, redactor, │ │
│ │ maintainer, known_fields │ │
│ │ } │ │
│ │ normalizers/<upstream>.go │ │
│ │ redactors/<upstream>.go │ │
│ │ known_fields/<upstream>.yml │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ internal/clients/offer_guardian.go │
│ type OfferGuardianClient interface { GetOffer(...), ... } │
│ ├── PROD path: SDK-backed impl │
│ └── TEST path: PS4 mock impl returning method-call-keyed fixtures ◄── Layer 1b
└──────────────────────────────────────────────────────────────────────────────────┘
│ (in prod) HTTPS / Bolt / etc to real upstreams
Real upstreams
(FIDORA, Button, Neptune, …,
Neo4j via docker-compose + seed — out of PS4)

All fixture files share this JSON shape:

{
"_meta": {
"upstream": "fidora", // or tool/client name for L1b/L2
"captured_at": "2026-05-21T10:32:11Z",
"captured_from": "stage-fido-assortment-service.us-east-1.stage-services.fetchrewards.com",
"captured_by": "f.luo", // or "hand-authored"
"ps4_version": "1",
"redacted_fields": ["body.user.email", "body.user.phone"]
},
"_key": {
// shape depends on layer; see below
},
"response": {
// L1: { status, headers, body }
// L1b: { return_value, error? }
// L2: { payload } (the unwrapped value the BaseTool would return)
}
}

The three _key flavors:

// L1 (HTTP-shaped) — fixture key = SHA-256(canonical JSON of this object after normalization):
"_key": {
"upstream": "fidora",
"method": "GET",
"path": "/view/fidors/fido/062ba603-...",
"query_canonical": "",
"body_canonical": ""
}
// L1b (method-call-shaped):
"_key": {
"client_name": "offer_guardian",
"method_name": "GetOffer",
"args_canonical": "{\"offerID\":\"off_abc\"}"
}
// L2 (BaseTool-shaped):
"_key": {
"tool_name": "fetch_product_context",
"args_canonical": "{\"product_id\":\"062ba603-...\",\"user_id\":\"u_hash\"}"
}

Canonicalization rules (applied by the normalizer before key derivation):

  • JSON: sort keys lexicographically, omit whitespace, normalize numbers to their shortest equivalent.
  • Strip headers: Authorization, X-Amz-Date, X-Amz-Security-Token, User-Agent, any X-Request-Id/X-Correlation-Id shape.
  • Strip body fields per upstream normalizer: timestamps, nonces, request IDs.
  • Neptune special: parameters field is a JSON-encoded STRING; nil params normalized to "{}".
  • Button special: cache-load batch endpoint collapses to a stable empty-args key (the response is the full merchant set).

Per-upstream normalizer code lives in internal/testing/ps4/normalizers/<upstream>.go. Per-upstream redactor code lives in internal/testing/ps4/redactors/<upstream>.go. Both are referenced by name from the upstream’s registration file at internal/testing/ps4/upstreams/<upstream>.go (see §5.3 for the registration mechanism that ties normalizer + redactor + hostname + allowlist + maintainer team together).

5.3 Layer 1 — Go HTTP RoundTripper substitution

Section titled “5.3 Layer 1 — Go HTTP RoundTripper substitution”

Insertion point: Today, each client in internal/clients/ constructs its own *http.Client (e.g., fidora.go:44, web.go:39, brightdata.go:94). Phase 1 introduces a shared builder clients.MakeHTTPClient that all clients must adopt; PS4 hooks the builder via a pair of build-tagged files so production code never reads PS4_MODE and never references ps4 package symbols:

// internal/clients/http.go (build-tag-agnostic; production-compatible)
// MakeHTTPClient is the single seam every client in this package uses to
// construct its *http.Client. The default Transport may be nil (DefaultTransport)
// or a custom transport (e.g., brightdata's proxy transport, which wraps base).
func MakeHTTPClient(timeout time.Duration, base http.RoundTripper) *http.Client {
if base == nil {
base = http.DefaultTransport
}
return installPS4(&http.Client{Transport: base, Timeout: timeout})
}
internal/clients/http_ps4test.go
//go:build ps4test
func installPS4(c *http.Client) *http.Client {
if mode := ps4.ModeFromEnv(); mode != ps4.ModeOff {
c.Transport = ps4.NewTransport(c.Transport, mode)
if mode == ps4.ModeCapture {
// Disable redirect-following on the client so capture-mode allowlist
// can't be bypassed via a 3xx to a non-allowlisted host (FR-L1-4 sub-bullet 3).
c.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
}
}
return c
}
internal/clients/http_noop.go
//go:build !ps4test
func installPS4(c *http.Client) *http.Client { return c } // production: no-op, PS4_MODE not even read

Custom-transport clients (e.g., brightdata.go’s proxy transport with TLSClientConfig) construct their custom RoundTripper and pass it to MakeHTTPClient(timeout, customTransport) — the seam still wraps it. Direct &http.Client{…} literals in internal/clients/ are forbidden by lint (FR-L1-1). This pattern satisfies FR-X-6 / AC-5c: production builds (without -tags ps4test) never compile in the ps4 package, never read PS4_MODE, and cannot install the substitution transport under any environment configuration.

internal/testing/ps4/transport.go (sketch — file is gated by //go:build ps4test per FR-X-6):

//go:build ps4test
package ps4
type Mode string
const (
ModeOff Mode = "off"
ModeReplay Mode = "replay"
ModeCapture Mode = "capture"
ModeEval Mode = "eval"
)
type Transport struct {
base http.RoundTripper // sentinel (refuses network) in ModeEval; real transport otherwise
mode Mode
fixtures *FixtureStore // testdata/fixtures/{upstream}/...
upstreamFor func(*http.Request) string // hostname → upstream-name; "" if unknown
normalizers map[string]Normalizer // upstream → normalizer (no nil entries; coverage enforced by NFR-4 test)
redactors map[string]Redactor // upstream → redactor
allowlist map[string]struct{} // exact-string set of allowlisted capture hostnames
}
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
upstream := t.upstreamFor(req)
if upstream == "" {
// Unknown hostname — fail in replay/eval (FR-L1-3, FR-L1-6); only off-mode falls through.
switch t.mode {
case ModeReplay, ModeEval:
if t.mode == ModeEval {
metrics.IncEvalBlocked("L1", "<unknown>")
}
return nil, &FixtureNotFoundError{Upstream: "<unknown>", Method: req.Method, Path: req.URL.Path, Host: req.URL.Host}
case ModeCapture:
return nil, &CaptureSourceNotAllowedError{Host: req.URL.Host} // unknown → not allowlisted
case ModeOff:
return t.base.RoundTrip(req)
}
}
norm := t.normalizers[upstream] // guaranteed non-nil by registration test (NFR-4)
key, err := norm.Key(req)
if err != nil { return nil, fmt.Errorf("ps4: normalize: %w", err) }
switch t.mode {
case ModeReplay, ModeEval:
f, ok := t.fixtures.Lookup(upstream, key)
if !ok {
if t.mode == ModeEval {
metrics.IncEvalBlocked("L1", upstream)
}
return nil, &FixtureNotFoundError{Upstream: upstream, Key: key, Method: req.Method, Path: req.URL.Path, Host: req.URL.Host}
}
return f.AsHTTPResponse(), nil
case ModeCapture:
if _, ok := t.allowlist[req.URL.Hostname()]; !ok { // hostname-only match; port checked separately if needed
return nil, &CaptureSourceNotAllowedError{Host: req.URL.Host}
}
redactor, ok := t.redactors[upstream]
if !ok {
return nil, &RedactorNotRegisteredError{Upstream: upstream} // fail-closed (FR-L1-4 sub-bullet 2)
}
resp, err := t.base.RoundTrip(req) // redirects disabled at http.Client.CheckRedirect
if err != nil { return nil, err }
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, &CaptureReadFailedError{Upstream: upstream, Err: err} // fail-closed: no partial fixture
}
resp.Body = io.NopCloser(bytes.NewReader(body)) // restore for caller
redactedBody, redactedFields := redactor.RedactBody(body)
keptHeaders, droppedHeaderFields := redactor.RedactHeaders(resp.Header)
if err := t.fixtures.WriteQuarantine(upstream, key, &Fixture{
Meta: NewMeta(req, redactedFields, droppedHeaderFields),
Key: keyObjectFor(upstream, req),
ResponseStatus: resp.StatusCode,
ResponseHeaders: keptHeaders,
ResponseBody: redactedBody,
}); err != nil {
return nil, &CaptureWriteFailedError{Upstream: upstream, Err: err} // fail-closed: surface to test
}
return resp, nil
}
panic("ps4: unreachable — switch over Mode is exhaustive; see FR-L1-1 enum")
}

Upstream identification (per-upstream registration, no central registry file): Each upstream has its own file at internal/testing/ps4/upstreams/<upstream>.go that registers itself via init():

//go:build ps4test
package upstreams
import "github.com/.../internal/testing/ps4"
func init() {
ps4.Register(ps4.UpstreamRegistration{
Name: "fidora",
Hostname: "stage-fido-assortment-service.us-east-1.stage-services.fetchrewards.com",
AllowlistCapture: true,
Normalizer: fidoraNormalizer{}, // implements ps4.Normalizer
Redactor: fidoraRedactor{}, // implements ps4.Redactor
Maintainer: "@fetch-rewards/data-platform", // GitHub team for cron @-mentions
KnownFields: "fidora.yml", // refers to known_fields/<upstream>.yml
})
}

A ps4.Registry (built from these init() calls) replaces what would otherwise be three separate central files (upstreams.go, allowlist.go, PS4_MAINTAINERS.yml) that every vertical adoption would touch. Each upstream’s file is owned by the platform team via CODEOWNERS but edited by only one PR at a time — eliminating the “N verticals onboard same week → N-way merge conflict on one file” failure mode Steve flagged on PS6 §5.4. Unknown hostnames return "" from upstreamFor and trigger the unknown-host branch above. NFR-4’s coverage test iterates the Registry and fails CI if any client in internal/clients/ lacks a matching registration entry.

Eval-mode sentinel transport: in ModeEval, t.base is an http.RoundTripper whose RoundTrip returns an error before any DNS resolution or socket open (FR-L1-6). Defense-in-depth: even if a bug in the lookup logic falls through to t.base.RoundTrip(req) in eval mode, the network is unreachable.

5.4 Layer 1b — Go SDK interface substitution

Section titled “5.4 Layer 1b — Go SDK interface substitution”

Pattern: any SDK-backed client in internal/clients/ exposes a Go interface; production wires the SDK impl; test wires the PS4 mock impl. Today this applies to offer_guardian.go.

internal/clients/offer_guardian.go
type OfferGuardianClient interface {
GetOffer(ctx context.Context, id string) (*Offer, error)
SearchOffers(ctx context.Context, q Query) ([]Offer, error)
// ...
}
type sdkOfferGuardian struct { sdk *og.Client }
func (s *sdkOfferGuardian) GetOffer(...) (...) { return s.sdk.GetOffer(...) }
// ...
// internal/testing/ps4/og_mock.go
type OGMock struct { fixtures *FixtureStore }
func (m *OGMock) GetOffer(ctx context.Context, id string) (*Offer, error) {
key := L1bKey{Client: "offer_guardian", Method: "GetOffer", Args: canonical(map[string]any{"id": id})}.Hash()
f, ok := m.fixtures.Lookup("offer_guardian", key)
if !ok { return nil, &FixtureNotFoundError{Upstream: "offer_guardian", Key: key} }
var out Offer
if err := json.Unmarshal(f.Response.ReturnValue, &out); err != nil { return nil, err }
return &out, nil
}

Production code already uses the interface; the test harness substitutes the impl via the existing builder pattern (WithOfferGuardian(...) from CLAUDE.md).

5.5 Layer 2 — Python BaseTool subclass substitution

Section titled “5.5 Layer 2 — Python BaseTool subclass substitution”

Convention (lives in consumer-agent):

# consumer-agent/<connectors>/mocks/fetch_product_context_mock.py
from <connectors>.fetch_product_context import FetchProductContextTool, ProductContextArgs
from <connectors>.mocks.fixture_loader import load_fixture
class FetchProductContextMock(FetchProductContextTool):
"""L2 mock. args_schema MUST equal the parent's (enforced by conformance test)."""
args_schema = ProductContextArgs # same class as parent
def _run(self, **kwargs) -> dict:
return load_fixture(
tool_name=self.name,
args=ProductContextArgs(**kwargs).model_dump(mode="json", exclude_none=True),
)["payload"]
async def _arun(self, **kwargs) -> dict:
return self._run(**kwargs)

load_fixture canonicalizes args (sorted-key JSON dump), hashes to find the fixture file under consumer-agent/<connectors>/mocks/fixtures/{tool_name}/{args_hash}.json, returns the parsed response.payload.

Conformance test (one per connector):

def test_mock_conforms_to_real():
assert FetchProductContextMock.args_schema is FetchProductContextTool.args_schema
assert FetchProductContextMock().name == FetchProductContextTool(...).name

L1 (already in §5.3): in PS4_MODE=eval the Transport never falls through to base (and base itself is a sentinel that errors before DNS/socket per FR-L1-6). fixture_not_found is the only outcome of a missing fixture; the metric ps4.eval_mode.real_call_blocked_total{layer="L1", upstream} increments on every fixture_not_found (lookup miss OR unknown hostname).

L2 requires a small additive change to PS2’s BaseTool base class. The guard fires at instantiation (FR-L2-4 / AC-9) AND on every _run/_arun call (FR-L2-5 / AC-9b). LangChain dispatches _run on the concrete subclass directly, so a super()._run() shim on the base wouldn’t fire — instead, __init_subclass__ wraps the subclass’s own _run/_arun at class-definition time so every real connector’s call goes through the guard:

# in PS2's connector base class (consumer-agent#286)
import os
from functools import wraps
from typing import ClassVar
# Captured exactly once, even on importlib.reload — see FR-L2-5.
if "_PS4_MODE_AT_PROCESS_START" not in globals():
_PS4_MODE_AT_PROCESS_START = os.environ.get("PS4_MODE", "off")
def _eval_mode_active() -> bool:
# OR semantics: either the startup snapshot OR the current env value being "eval" trips.
# Defeats both "tests set PS4_MODE then construct" and "construct then mutate env".
return _PS4_MODE_AT_PROCESS_START == "eval" or os.environ.get("PS4_MODE") == "eval"
def _check_eval_guard(connector_name: str, stage: str, is_mock: bool) -> None:
if is_mock:
return
if _eval_mode_active():
metrics.inc_eval_blocked(layer="L2", connector=connector_name, stage=stage)
raise EvalModeRealConnectorBlocked(connector_name, stage=stage)
class PS2BaseTool(BaseTool):
_is_mock: ClassVar[bool] = False # mock subclasses set True
def __init__(self, *args, **kwargs):
_check_eval_guard(type(self).__name__, "init", self._is_mock)
super().__init__(*args, **kwargs)
def __init_subclass__(cls, **kwargs):
# Wrap the subclass's _run/_arun so the guard fires regardless of override.
# Sync method gets a sync wrapper; async method (_arun, or any iscoroutinefunction)
# gets an async wrapper so awaiting the wrapped coroutine still works.
super().__init_subclass__(**kwargs)
import inspect
for method_name in ("_run", "_arun"):
original = cls.__dict__.get(method_name)
if original is None:
continue # subclass didn't override at this level
if getattr(original, "_ps4_guard_wrapped", False):
continue # already wrapped (e.g., re-bound from a parent)
if inspect.iscoroutinefunction(original):
@wraps(original)
async def wrapper(self, *a, _orig=original, **kw):
_check_eval_guard(type(self).__name__, "run", self._is_mock)
return await _orig(self, *a, **kw)
else:
@wraps(original)
def wrapper(self, *a, _orig=original, **kw):
_check_eval_guard(type(self).__name__, "run", self._is_mock)
return _orig(self, *a, **kw)
wrapper._ps4_guard_wrapped = True # sentinel prevents double-wrap on re-binding
setattr(cls, method_name, wrapper)

Mock subclasses set _is_mock = True as a class attribute. PS4 conformance test asserts: (a) the guard exists; (b) instantiating a real connector under PS4_MODE=eval raises (AC-9); (c) post-init os.environ["PS4_MODE"] = "eval" followed by _run(...) raises on the per-call check (AC-9b); (d) importlib.reload of the PS2 base module does NOT reset _PS4_MODE_AT_PROCESS_START.

Binary at consumer-context-service/cmd/ps4/:

ps4 list [--upstream X] [--age '>Nd' | '<Nd']
ps4 validate [path] # default: walk testdata/fixtures/
ps4 capture --test <test_name> --output <dir>

Scope: v1 ps4 list enumerates L1 + L1b fixtures in the CCS repo only (per AC-10). L2 fixtures live in a separate repo and are listed via a small Python equivalent (python -m connectors.mocks.list) shipped alongside L2 in consumer-agent.

ps4 validate checks: required _meta fields present; _meta.captured_at parses as RFC 3339; _key shape matches the upstream’s expected flavor (L1, L1b, or L2); response body parses as the upstream’s declared content-type; redactor-known PII patterns (NFR-5 enumeration) absent from the response body and headers (defense-in-depth); file size ≤ NFR-2 cap; if the fixture has _meta.hand_modified=true, _meta.captured_at MUST not be older than the file’s modification time. Used by CI lint.

ps4 capture mechanism: the binary is an env-var wrapper that:

  1. Sets PS4_MODE=capture, PS4_CAPTURE_QUARANTINE=<temp-dir>, PS4_CAPTURE_OUTPUT=<--output dir> in the child environment.
  2. Shells out to go test -tags ps4test -run <test_name> so the build-tag-gated PS4 transport is compiled in (FR-X-6).
  3. The Transport (running inside the test process) writes captured fixtures to the quarantine dir (FR-L1-4 sub-bullet 5).
  4. After go test exits, ps4 capture runs ps4 validate on every quarantined file.
  5. Validated files are moved to --output; failed files stay in quarantine with a stderr summary.

No raw “interception” — the test process produces the files; the CLI wraps env + post-validation. Capture mode’s allowlist enforcement (FR-L1-4 sub-bullet 1) lives in the Transport, not the CLI.

5.7.1 Onboarding model — what an author writes vs what the framework gives them

Section titled “5.7.1 Onboarding model — what an author writes vs what the framework gives them”

PS4 is consumed by two kinds of authors: the platform team (this team — owns L1/L1b/L2 mechanics, the registry, the CLI) and vertical / per-team authors who add coverage for a specific upstream (L1/L1b) or a specific BaseTool connector (L2). The compartmentalization model:

Author writes (per upstream / per tool)Framework provides (once, here)
internal/testing/ps4/upstreams/<upstream>.go — one UpstreamRegistration (hostname, allowlist flag, normalizer ref, redactor ref, maintainer team, known-fields YAML pointer)The ps4.Registry machinery, init()-glob loading, NFR-4 coverage assertion
internal/testing/ps4/normalizers/<upstream>.go — implements ps4.Normalizer (one method: Key(*http.Request) (KeyObject, error)); ~30-50 lines, strips upstream-specific non-determinismCanonical-JSON utilities, SHA-256 hashing, the _key flavor selection
internal/testing/ps4/redactors/<upstream>.go — implements ps4.Redactor (RedactBody, RedactHeaders); unit test consumes testdata/pii_synthetic.json per NFR-5The PII pattern catalog (NFR-5 enumeration), the response-header authoritative allowlist, the test scaffolding that runs the synthetic against every registered redactor
internal/testing/ps4/known_fields/<upstream>.yml — the top-level field names this upstream’s response is permitted to carryps4 validate’s diff check, the AC-15 drift-cron consumption
testdata/fixtures/<upstream>/*.json — fixture files, hand-authored or ps4 capture-generatedThe fixture file format, _meta schema, _key flavors, fixture lookup, replay/capture/eval modes
(L2 only) consumer-agent/<connectors>/mocks/<tool>_mock.py — one BaseTool subclass with matching args_schemaThe __init_subclass__ eval-mode guard (in PS2 base class), the fixture loader, the conformance test scaffolding
(L2 only) consumer-agent/<connectors>/mocks/fixtures/<tool>/*.json — hand-authored payload fixturesSame fixture file format as L1; Python validator that runs NFR-5 PII scan

The platform team’s commitment: a new upstream’s onboarding is 4 files (~100 lines total) plus fixtures, all under that upstream’s name — never editing a shared file with another vertical. Each upstream’s CODEOWNERS surface is its own subdir; platform-security review fires automatically per FR-X-4.

Worked example: onboarding pointpass to L1

Section titled “Worked example: onboarding pointpass to L1”

A reference walkthrough showing the full diff a vertical author opens. Assume pointpass.go already exists in internal/clients/ and goes through MakeHTTPClient per FR-L1-1.

1. Registration (internal/testing/ps4/upstreams/pointpass.go, ~15 lines):

//go:build ps4test
package upstreams
import "github.com/.../internal/testing/ps4"
func init() {
ps4.Register(ps4.UpstreamRegistration{
Name: "pointpass",
Hostname: "stage-pointpass.fetchrewards.com",
AllowlistCapture: true,
Normalizer: pointpassNormalizer{},
Redactor: pointpassRedactor{},
Maintainer: "@fetch-rewards/loyalty-platform",
KnownFields: "pointpass.yml",
})
}

2. Normalizer (internal/testing/ps4/normalizers/pointpass.go, ~25 lines): strip Authorization + X-Amz-Date headers, leave path+query intact (pointpass uses path-encoded user IDs which ARE part of the key).

3. Redactor (internal/testing/ps4/redactors/pointpass.go, ~40 lines): drop Set-Cookie, redact progress.user.email, progress.user.phone, tiers[].user_display_name, leave numeric tier_id / points intact. Unit test runs NFR-5 synthetic.

4. Known fields (internal/testing/ps4/known_fields/pointpass.yml, ~10 lines): list the top-level response keys (progress, tiers, quests, stickers, meta).

5. First fixture — engineer runs ps4 capture --test TestPointPassEnrich --output testdata/fixtures/pointpass/. The Transport stages to testdata/fixtures.pending/pointpass/, redactor runs, ps4 validate runs, validated file moves to output.

6. PR with the four new files + the captured fixture. CI: NFR-4 coverage test passes (registration entry present), redactor unit test passes (NFR-5 synthetic covered), ps4 validate passes, age warning silent (fresh capture). The ps4-fixture-review label is required (FR-X-4); CODEOWNERS auto-requests platform-security review.

No central file edited. No other vertical’s PR affected. The Registry-iteration tests catch any cross-cutting regression.

Per-fixture metadata (_meta.captured_at): every fixture carries the capture timestamp. Fixtures with _meta.hand_modified=true are excluded from drift checks (per FR-X-2).

CI age warning (non-blocking): a step in lint.yml walks fixtures, finds any older than 90 days, and prints to the PR summary. Build does not fail.

Weekly cron (.github/workflows/fixture-refresh.yml, opt-in per upstream): runs the capture suite against stage and diffs against committed fixtures. Fail-closed semantics per AC-15 (see §4.3 for the normative requirement).

Supporting state:

  • internal/testing/ps4/known_fields/<upstream>.yml — per-upstream allowlist of top-level response field names the redactor has classified. Used by AC-15 step 2 to detect “new field, unknown PII status.” Lint trigger: when any committed fixture under testdata/fixtures/<upstream>/ (or its L1b/L2 equivalent) contains a top-level response.body field not listed in known_fields/<upstream>.yml, ps4 validate fails with a clear error. This forces the YAML to stay in sync with the fixtures the redactor has actually been exercised against; the drift cron then has an authoritative classification list to compare live-response top-level fields against.
  • The maintainer team for @-mentions / private-channel notifications comes from the same per-upstream registration file at internal/testing/ps4/upstreams/<upstream>.go (the Maintainer field in UpstreamRegistration, see §5.3). A small cmd/ps4-maintainers helper compiles the registry and emits the upstream → team map the cron reads — no single centralized PS4_MAINTAINERS.yml to collide on.

Per-upstream files (upstreams/<upstream>.go, known_fields/<upstream>.yml, redactors/<upstream>.go) are covered by the FR-X-4 CODEOWNERS requirement — platform-security approval is required for additions and edits, but each vertical adoption touches its own files, eliminating the merge-conflict collision surface Steve flagged on PS6’s central internal/registry.

ConsumerImpactNotes
PS6 (004-ps6-bff-enrichment)Enricher unit + integration tests run against L1 fixtures. PS6 envelope shape (status / partial / cache_meta) MUST be exercisable via L1 — fixtures must be able to express upstream failure modes that produce status=partial / status=error envelopes.Hard requirement. PS6 conformance test (PS6 §9.4 AC-7) uses L1 fixtures.
PS2 connector framework (consumer-agent#286)PS2 §6 (PS4 row) and §9.4 explicitly delegate BaseTool mocks to PS4. L2 lives in consumer-agent next to the real connectors. PS4 owes PS2 the conformance test asserting args_schema parity. PS2 owes PS4 the eval-mode guard (small additive change in PS2 base class).Hard blocker on L2 work: PS4 L1 + L1b can proceed independently (Phases 1–3 of §10.1). PS4 L2 work (Phase 4 onwards) MUST wait for PS2’s PR to merge — L2 inherits PS2’s BaseTool interface and the eval-mode guard ships in PS2’s base class. Spec-text changes to PS2’s draft trigger spec-text updates here, but the implementation is held until merge.
PC5 agent evals (PLT-683)Eval runs MUST use PS4 fixtures at both layers AND MUST be prevented from reaching real upstreams. PS4_MODE=eval is the contract. PC5 owns the eval-runner config that sets PS4_MODE=eval and the promotion pipeline gates; PS4 provides the substitution + metric.Eval-mode metric ps4.eval_mode.real_call_blocked_total is the canary. PS4’s PS4_MODE=eval plug-in to PC5’s eval runner is the integration seam.
PF4 (Security & Auditability)PS4’s capture-mode redactor + capture-source allowlist + PR-label gate + CODEOWNERS-on-per-upstream-registration are PF4 controls applied to test data. Principal-mismatch / unredacted-PII events at the drift cron (AC-15) SHOULD flow into PF4’s joined trace + SSE store once PF4 lands.Cite PF4 for v2 audit-trail work; PS4 v1 emits the metrics PF4 will consume.
PF8 (Cross-Vertical Observability Conventions)The ps4.* metric namespace (§10.2) aligns with PF8’s naming + Grafana panel templates. Alert thresholds (ps4.eval_mode.real_call_blocked_total > 0, ps4.drift_cron.diffs_opened_total weekly trend) MUST be defined per PF8’s template.Cite PF8 once metrics + panels land; coordinate Grafana dashboard naming.
Vertical specs (S1a–S1g)Each vertical’s enricher MUST have an L1 fixture set in testdata/fixtures/<upstream>/ AND an L2 BaseTool mock in consumer-agent/<connectors>/mocks/ (when the vertical exposes a BaseTool). Conformance tests at both layers run per-vertical.Born-conformant from day one (PS6 W5 / FR-10).
Notification ServiceNo direct impact — NS reads PS6 envelopes; it doesn’t call CCS upstreams or PS2 connectors. NS-side mocks for PS6 envelope inputs are NS’s concern, out of PS4 scope.No-op.
consumer-agent (non-PS2 paths)If consumer-agent has non-PS2 code paths that call CCS directly (legacy, REST), PS4 L2 does NOT cover them; that code path SHOULD migrate to PS2 connectors, after which L2 covers it.Migration concern, not PS4 scope.
SpecDirectionNotes
PS2 connector framework (PLT-680 / consumer-agent#286)PS4 depends onL2 mocks subclass PS2’s BaseTool interface; PS2 must merge first or change in lockstep
PS6 (004-ps6-bff-enrichment)PS4 enablesPS6 enricher tests + conformance tests consume L1 fixtures
PC5 agent evals (PLT-683)PS4 enablesEval determinism at both layers is gated on PS4
Q2 Verticals Spec Lab (S1a–S1g)PS4 enablesPer-vertical L1 fixture sets + L2 mock subclasses
Parent epicPLT-676 (Platform Spec Lab)

This section is the canonical decisions record for the spec.

Original OQResolution
OQ-1: Deployment shape (in-process vs sidecar)In-process Go middleware via http.RoundTripper installed by clients.MakeHTTPClient on every client’s *http.Client (build-tag-gated)
OQ-2: SDK-level upstreams (Offer Guardian)Wrap SDK behind a CCS interface; mock the interface. Method-call-shaped fixtures (Layer 1b). Template for any future SDK upstream.
OQ-3: Special-cased upstreamsNeo4j — stays as-is (real instance via docker-compose + seed). Python sidecars — deprecated; out of scope. web.go (generic web fetcher) — PS4 covers via L1. Neptune — L1 covers when CCS adopts a Neptune client; mechanism designed now.
OQ-4: PII redaction in capture modeThree-layer defense: capture-source allowlist + automated per-upstream redactor + mandatory ps4-fixture-review PR label (CI lint gates)
OQ-5: Fixture authoring CLI scopeMinimal v1: ps4 list / validate / capture. No editor / TUI / diff in v1.
OQ-6: Fixture drift detectionPer-fixture captured_at + CI age warning (90 days, non-blocking) + opt-in weekly cron diff against stage (opens tracking issue)
OQ-7: PS2 seamPS2 owns the interface; PS4 inherits. L2 mocks subclass PS2’s BaseTool. PS2 (consumer-agent#286) must merge first or update in lockstep.
OQ on PS4 scope (raised by PS2 PR review)Both layers under PS4 — L1 (Go RoundTripper) AND L2 (Python BaseTool mocks). L2 mocks live in consumer-agent next to real connectors.
RiskSeverityMitigation
PS2 interface changes during review (consumer-agent#286 is draft)MediumL2 conventions documented relative to PS2’s current draft (§5.5); update PS4 if PS2 changes shape. L2 implementation is held until PS2 merges (§6 PS2 row).
Redactor false negative — PII pattern slips past automated rulesHighCapture-source allowlist (exact-host match) + ps4-fixture-review PR label + CODEOWNERS security review + per-upstream redactor unit tests asserting NFR-5’s full PII enumeration + high-PII upstreams use allowlist-of-retained-fields rather than denylist
Fixture drift accumulates faster than triageMediumWeekly cron opens issues with maintainer @-mentions; if backlog grows, escalate (block PRs touching a stale upstream’s enricher)
L1 RoundTripper hostname-mapping wrong (request escapes to a real upstream in replay)HighPer-upstream registration in internal/testing/ps4/upstreams/<upstream>.go (init()-built Registry, see §5.3); NFR-4 CI test iterates the registry and fails if any client in internal/clients/ lacks an entry; replay AND eval mode are fail-closed by construction; eval mode adds a sentinel transport beneath the lookup (FR-L1-6)
Per-test ad-hoc httptest.Server stubs persist alongside PS4MediumPhase 6 migration plan (§10.1) + lint rule banning httptest.Server in internal/clients/ after Phase 6
L2 args_schema drift between mock and real connectorMediumNFR-6 conformance test fails CI when schemas diverge
Capture mode used against prod despite allowlist (operator error)HighAllowlist exact-string match built at init from compiled-in per-upstream registration files (see §5.3), not config; refusal is a returned error not a log warning; build tag (FR-X-6) keeps PS4 transport out of production binaries entirely
Concurrent capture-mode writes to same fixture keyLowCapture mode is single-process per ps4 capture invocation; quarantine directory writes use unique tempfile names; promotion to --output is atomic (rename). Parallel go test packages writing to the same key remains a hazard — last-writer-wins. Documented; engineers running parallel capture should partition by upstream.
Hand-edited captured fixtures cause false drift signalsLowFR-X-2 requires _meta.hand_modified=true when a captured fixture is hand-edited; drift cron (§5.8) excludes hand-modified fixtures. Lint check verifies _meta.hand_modified=true if _meta.captured_at < file mtime.
Drift cron leaks PII via posted diffsHighAC-15 fail-closed: redactor runs on both sides pre-diff; new top-level fields trigger a private-channel maintainer notification (field names only, no values), not a public GitHub issue; full diff stored in private artifact, not issue body
Build-tag isolation circumvented in CIMediumLint rule + Go build asserts the ps4test tag is not set on the make build / make release paths; CI step verifies the prod binary fails fast if PS4_MODE is set (AC-5c)
SDK substitution overused for clients that don’t need itLow§5.4 documents the pattern is for SDK-wrapped upstreams only; raw-HTTP clients use L1

Unit tests for PS4 itself (in internal/testing/ps4/):

  • Normalizer correctness per upstream — given a request with non-deterministic fields, the produced key is stable.
  • Redactor coverage per upstream — given a synthetic response containing known PII patterns, redactor removes them and records them in _meta.redacted_fields.
  • Transport mode switching — replay/capture/eval/off behave per FRs; mode transitions don’t leak state.
  • Capture-source allowlist — prod-shaped hostnames refused; allowlisted hostnames pass.
  • Fixture store I/O — read/write/lookup round-trips byte-for-byte.

Integration tests (in internal/<domain>/... test files):

  • Every CCS client with L1 coverage has at least one integration test that runs through L1 in replay with a committed fixture.
  • L1b: Offer Guardian enrichment path has a test running through the mock impl.

Conformance tests:

  • L1 coverage: a meta-test iterates ps4.Registry.All() (built from internal/testing/ps4/upstreams/<upstream>.go init() calls per §5.3) and asserts: (a) every UpstreamRegistration has non-nil Normalizer + Redactor, (b) every hostname in internal/clients/<upstream>.go config has a matching UpstreamRegistration, (c) the reverse — every UpstreamRegistration.Hostname resolves to a real client in internal/clients/. Fails CI if any check fails. This replaces the simpler “enumerate internal/clients/” check by making the Registry the source of truth.
  • L2 schema parity (per connector in consumer-agent): assert mock.args_schema is real.args_schema.
  • L2 eval guard — instantiation (AC-9): assert that with PS4_MODE=eval and _is_mock=False, instantiating a real connector raises EvalModeRealConnectorBlocked.
  • L2 eval guard — per-call re-check (AC-9b / FR-L2-5): given a real connector constructed under PS4_MODE=replay, mutate os.environ["PS4_MODE"]="eval" post-init, then invoke _run(...); assert EvalModeRealConnectorBlocked raises on the call (not on init). Also assert importlib.reload of the PS2 base module does NOT reset _PS4_MODE_AT_PROCESS_START.

Eval-mode guard test: a dedicated test that asserts ps4.eval_mode.real_call_blocked_total is 0 at the end of a healthy PS4_MODE=eval test run; non-zero is treated as a misconfiguration regression.

CLI tests: cmd/ps4/ test suite exercises list, validate, capture against a temporary testdata/ directory.

Cron job test: .github/workflows/fixture-refresh.yml has a dry-run mode invokable in PR to verify the diff/issue-opening path without actually opening issues.

  • Phase 1 — L1 mechanism + 3 upstreams (replay-only): PS4 RoundTripper lands as internal/testing/ps4 under build tag //go:build ps4test. Per-upstream registration files for FPS + FIDORA + Button (§5.3 pattern). Replay mode + eval mode (with sentinel transport) only — capture mode does NOT ship in Phase 1. Hand-authored fixtures for the 3 upstreams. CLI list + validate. CI age-warning lint. No PR-label gate yet (no capture-generated fixtures exist; hand-authored fixtures go through normal review).
  • Phase 1.5 — Review gate: Once FPS + FIDORA + Button have shipped fixtures and at least one PS6 enricher integration test runs against the substitution, hold a contract-narrowness review. The v1 PS4 abstraction is extracted from 3 raw-HTTP upstreams; before onboarding all 16 (Phase 2), check whether the L1 contract holds for the harder cases (Button’s full-cache load, eReceipt’s dual-provider parallel check, brightdata’s custom-transport proxy). If the abstraction needs to bend, bend it now — not after every vertical has authored their fixture set. Outcome is a short writeup; no code blocker unless contract changes are needed.
  • Phase 2 — L1 capture mode + redactors + PR-label gate + all hosted upstreams: Capture mode + per-upstream redactors + capture-source allowlist + quarantine flow. Coverage extends to all L1 upstreams in §3.1 (excluding Neptune until CCS adopts it). ps4 capture CLI lands. ps4-fixture-review PR-label CI gate lands (now meaningful, since capture-generated fixtures exist). CODEOWNERS entry on per-upstream registration paths.
  • Phase 3 — L1b (Offer Guardian): SDK wrapper interface in internal/clients/offer_guardian.go; PS4 mock impl. Method-call-shaped fixtures for OG. L1b capture path reuses Phase 2’s redactor + label-gate infra.
  • Phase 4 — L2 (BaseTool mocks in consumer-agent) [blocked on PS2 merge]: Held until PS2 (consumer-agent#286) merges. L2 mock convention rolls out per connector. Conformance test lands. PS2’s eval-mode guard wired (instantiation + per-call re-check). Consumer-agent CI inherits the ps4-fixture-review label gate + CODEOWNERS rules for the L2 fixture path. Python ps4 validate port (or wrapped Go invocation) lands.
  • Phase 5 — Eval-mode wiring + PC5 integration: PS4_MODE=eval wired into PC5 eval runner. Eval-mode metrics surfaced. PC5 eval suite migrated to PS4 fixtures.
  • Phase 6 — Migration + cleanup: Existing per-test httptest.Server stubs in internal/clients/ migrated to PS4 fixtures. Lint rule banning httptest.Server in internal/clients/ lands. Drift cron opt-ins extend to all upstreams.
  • Phase 7 — Neptune coverage [blocked on CCS adopting a Neptune client]: When CCS gains a Neptune client (separate migration timeline), register its hostname + normalizer (Neptune parameters-as-JSON-string rule) + redactor; author initial fixtures.
MetricLabelsMeaning
ps4.fixture.requests_totallayer, upstream, mode, outcome ∈ {hit, miss, refused, fallthrough}Per-call accounting; outcome=miss in replay/eval is the regression signal
ps4.eval_mode.real_call_blocked_totallayer, upstream (L1) or connector (L2)MUST be 0 in healthy eval runs; non-zero = misconfiguration alert
ps4.fixture.capture_redacted_fields_totalupstream, fieldWhat the redactor removed; spike = upstream schema change or new PII pattern
ps4.fixture.age_daysupstream, fixture_key (sampled / coarse)Drift signal; correlates with cron findings
ps4.drift_cron.diffs_opened_totalupstreamWeekly issues opened by the drift cron; tracks triage backlog

Logs: structured per-call records (upstream, key, mode, outcome) at debug level. Capture-mode writes log at info level (which fixture file, which _meta.redacted_fields).

See §3.1 table. Source: internal/clients/ directory listing, CLAUDE.md “Upstream Service URL Patterns”.

The existing Go Neptune client at consumer-graph-capacity-experiments/internal/neptune/client.go exposes Config.HTTPClient *http.Client (line 30). PS4’s L1 substitution works against it with zero client-side changes — the test wires a PS4 RoundTripper into the config. One normalization rule to pin: Neptune’s parameters field is a JSON-encoded STRING, and nil parameters are normalized to "{}" (the Neptune server rejects "null" with MalformedQueryException).