PS4 — Mock Proxy & Test Doubles
PS4 — Mock Proxy & Test Doubles
Section titled “PS4 — Mock Proxy & Test Doubles”1. Problem Statement
Section titled “1. Problem Statement”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:
- CI integration tests (Surface A). Today CCS integration tests use
docker-compose.ymlto 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-hochttptest.Servers that duplicate setup code across every test file. - 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.
- 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:
- Non-deterministic CI. Test flakes track staging health, not code correctness. Engineers learn to ignore CI failures.
- 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.
- 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.RoundTrippersubstitution 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
BaseToolmock 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
_keycontents 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.
2. Capabilities Source
Section titled “2. Capabilities Source”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.
3. Background & Context
Section titled “3. Background & Context”3.1 Current State
Section titled “3.1 Current State”CCS upstream surface (verified against internal/clients/ directory listing as of branch HEAD): three distinct staging URL conventions; the full list of clients:
| Client file | Upstream | PS4 coverage |
|---|---|---|
fps.go | FIDO Product Service | L1 — REST JSON, stable shape |
fidora.go | FIDORA (assortment) | L1 — REST JSON |
button.go | Button merchants | L1 — full-cache load on startup; normalizer must collapse the cache-load batch to a stable key |
button_partners.go | Button partner data | L1 |
retailer.go | Retailer Service | L1 |
ereceipt.go | eReceipt provider + handbook | L1 — dual-provider parallel check; both endpoints fixtured independently |
offer_guardian.go | Offer Guardian SDK | L1b — SDK-level wrapped behind a CCS interface; method-call-shaped fixtures |
fido_index.go | OG reverse index | L1 (HTTP refresh path) |
neli.go | NELI eligibility | L1 |
pointpass.go | PointPass (L5 features) | L1 |
purchase_history.go | Purchase History | L1 |
offer_search.go | Offer Search (ML) | L1 |
fido_search.go | FIDO Search (ML) | L1 |
lidar.go | LIDAR (location offers) | L1 |
web.go | Generic web page fetcher | L1 — high variability, ideal RoundTripper substitution candidate |
brightdata.go | BrightData SERP proxy | L1 — custom-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.go | Neo4j (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) Neptune | Neptune 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.go | Python sidecars | Out 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
BaseToolsubclass with a Pydanticargs_schema, a_runmethod (and optionally_arun), and aname/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
BaseToolshape” (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).
3.3 Key Decisions Already Made
Section titled “3.3 Key Decisions Already Made”| Decision | Rationale | Source |
|---|---|---|
| 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 PS4 | PLT-688 + PS2 §6, §9.4 |
| Substitution at the transport boundary (L1) or via subclass (L2), not per-test branching inside production logic | Production code path stays identical between test and prod; one substitution surface per layer | Frontmatter constraints |
| Fixtures live in source control | Reproducibility — a test that fails in CI must fail on the engineer’s laptop with the same data | Platform Spec Lab convention |
| Capture mode refuses prod-shaped hostnames (allowlist) | Defense-in-depth against PII leak into fixture files | Brainstorm 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 tests | Brainstorm OQ-3; carries through migration |
| Python sidecars are deprecated → out of PS4 scope | They’re being removed from CCS | Brainstorm OQ-3 |
| Eval mode is env-var-gated and fails closed on real-upstream calls | PC5 grading is unreproducible if upstreams are live; soft enforcement is not enforcement | PLT-688 ticket; brainstorm |
3.4 Glossary
Section titled “3.4 Glossary”| Term | Definition |
|---|---|
| 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 |
| Fixture | A committed, deterministic JSON file with _meta + _key + response sections |
| Fixture key | SHA-256 hash of the canonical _key object after per-upstream/per-tool normalization |
| Normalizer | Per-upstream/per-tool code that strips non-deterministic fields (timestamps, request IDs, nonces, auth tokens) from a request before key derivation |
| Redactor | Per-upstream code that strips PII / secrets from a captured response before the fixture is persisted |
| Replay mode | Operating mode where PS4 serves fixtures and refuses to call real upstreams |
| Capture mode | Operating mode where PS4 records real upstream responses (from allowed sources only) and writes redacted fixture candidates |
| Eval mode | A 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 allowlist | Compiled-in list of hostnames PS4 capture mode is permitted to record from (stage, local, synthetic-fixture environments) |
ps4-fixture-review label | GitHub PR label required on any PR introducing a capture-mode-generated fixture; CI lint fails without it |
| UpstreamRegistration | Per-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() |
| Registry | Process-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 |
4. Requirements
Section titled “4. Requirements”4.1 Functional Requirements
Section titled “4.1 Functional Requirements”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.RoundTripperon every*http.Clientused by clients ininternal/clients/. Today each client constructs its own*http.Clientindependently (e.g.,fidora.go,web.go,brightdata.go); this spec mandates that ALL such constructions route through a shared builderclients.MakeHTTPClient(timeout time.Duration, base http.RoundTripper) *http.Clientintroduced 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 passnilto gethttp.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 perPS4_MODE. Enablement is via env varPS4_MODE∈ {off,replay,capture,eval}; defaultoff. The substitution code is compiled out of the production binary via build tag//go:build ps4test(see FR-X-6) —PS4_MODEis a no-op in prod builds even if set. A CI lint MUST fail any direct&http.Client{…}literal ininternal/clients/outside ofclients.MakeHTTPClient. Pre-existing custom-transport clients (e.g.,brightdata.go’s proxy transport) MUST take aRoundTripperargument and wrap it throughMakeHTTPClient’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
_keyobject 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., Neptuneparametersfield is a JSON-encoded STRING andnilis normalized to"{}". The fixture key isSHA-256(canonical JSON of normalized _key). -
FR-L1-3: In
replaymode ANDevalmode, the RoundTripper MUST look up the fixture by key and return itsresponse.{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 wrappingfixture_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 inoffmode. -
FR-L1-4: In
capturemode, the RoundTripper MUST:- 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_allowederror, no fixture written. - Refuse to record if the upstream has no registered redactor in
redactors[<upstream>](fail-closed default) — returnsredactor_not_registered. This guarantees no upstream can be captured before its redactor + redactor unit tests have landed. - Disable HTTP redirect-following on the
http.Clientused in capture mode (setClient.CheckRedirectto returnhttp.ErrUseLastResponse— redirects are ahttp.Clientconcern, nothttp.Transport) — prevents an allowlisted host from redirecting to a non-allowlisted one mid-call. - 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 asSet-Cookie,Authorization,WWW-Authenticate,Proxy-Authenticate, and anyX-Amz-*are dropped by this rule — they are listed in the redactor implementation as illustrative test cases, not as an additional denylist. - Stage the fixture into a quarantine directory (
testdata/fixtures.pending/<upstream>/) and runps4 validateagainst it before promoting totestdata/fixtures/<upstream>/. Validation failure → fixture stays quarantined. - Write
_meta.captured_at,_meta.captured_from(thereq.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 centralallowlist.goto collide on; each upstream’s entry lives in its own file. - Refuse to record if the request hostname is not on the compiled-in capture-source allowlist (see definition below) — refusal returns a structured
-
FR-L1-5: In
evalmode, the RoundTripper MUST behave likereplaymode AND MUST emit a metricps4.eval_mode.real_call_blocked_total{layer="L1", upstream=…}on anyfixture_not_foundoutcome. 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>.gowhose 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 coversOfferGuardianClient; the convention is documented for future SDK upstreams. - FR-L1b-2: Method-call fixture files use the same JSON shape as L1; only the
_keyshape 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
BaseToolconnector in consumer-agent that wraps a CCS endpoint, PS4 MUST provide a mock subclass with the samename, the sameargs_schema, and a_run/_arunthat 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 identicalargs_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=evalvia 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. Metricps4.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/_aruninvocation (not only at__init__). Goal: a test that mutatesos.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_metaschema (FR-X-2), sameps4-fixture-reviewPR 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’sresponse.payloadfor 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 ofps4 validateor 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_atto the edit time AND append a_meta.hand_modifiedboolean=true; the cron diff (§5.8) MUST exclude fixtures with_meta.hand_modified=truefrom drift checks. - FR-X-3: The
ps4CLI 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 theps4-fixture-reviewlabel 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_fromto"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 ps4testso production Go builds CANNOT enable substitution under any environment configuration —PS4_MODEis not read by production Go code. For the Python L2 path, the guard in PS2’s BaseTool base class DOES readPS4_MODEat runtime — this is acceptable because (a) the read is a no-op whenPS4_MODEis unset (the production state), and (b) the guard’s only behavior is to raiseEvalModeRealConnectorBlockedwhenPS4_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=captureMUST 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 insideinternal/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_fieldsview, not the raw response. A trivial CI grep MUST flag anyt.Log/t.Logf/fmt.Printlnliteral ofresp.Body/resp.Headerininternal/clients/*_test.goandinternal/testing/ps4/**; this is a coarse static check, not an attempt to detect arbitrary dataflow.
4.2 Non-Functional Requirements
Section titled “4.2 Non-Functional Requirements”- 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_versionis bumped only for breaking changes to the fixture file shape, the_keycanonicalization rules, or the hashing algorithm — i.e., changes that invalidate existing fixtures. Adding new optional_metafields, 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.jsonand 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-Cookieheader 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_schemaMUST equal the real connector’sargs_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=evalMUST NOT produce anyps4.eval_mode.real_call_blocked_totalincrements under normal operation. Any increment is treated as a misconfiguration regression.
4.3 Acceptance Criteria
Section titled “4.3 Acceptance Criteria”- 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 andlsof/ netstat observation confirms no socket left the process. - AC-2 (L1 replay miss): Given
PS4_MODE=replayand a request with no matching fixture, when the client calls the upstream, then the response is a structuredfixture_not_founderror containing{upstream, method, path, computed_key}, AND no real-network call is made. - AC-3 (L1 capture redaction): Given
PS4_MODE=captureagainst 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_fieldslists 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=captureand a target whosereq.URL.Hostname()does NOT exactly match an entry compiled in from the per-upstream registration files atinternal/testing/ps4/upstreams/**(no glob, no suffix match, no header-derived host), when the call is initiated, then PS4 returnscapture_source_not_allowedand writes no file. The denial MUST also fire if a 3xx redirect would land on a non-allowlisted host (redirects are disabled at thehttp.Client.CheckRedirectlevel in capture mode). - AC-4b (Missing redactor refusal): Given
PS4_MODE=captureand an upstream with no registered redactor (i.e., not yet onboarded per NFR-4), when capture is initiated, then PS4 returnsredactor_not_registeredand writes no fixture. - AC-5 (Eval mode L1): Given
PS4_MODE=evaland any code path that would reach the real network, when the call attempts to proceed, then the RoundTripper returnsfixture_not_found(no fallthrough is attempted; FR-L1-6’s sentinel transport also blocks the call beneath the lookup), andps4.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=evaland a request to a hostname not registered in the upstream-map, when the RoundTripper is reached, then the result isfixture_not_foundwithupstream="<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 setPS4_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 callsOfferGuardianClient.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
FetchProductContextToolin consumer-agent with anargs_schema = ProductContextArgs(BaseModel), when the conformance test runs, then the L2 mock subclass hasargs_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=evaland any test that tries to instantiate a real (non-mock) BaseTool connector, when construction is attempted, then the PS2 guard raisesEvalModeRealConnectorBlocked, andps4.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, whenPS4_MODEis mutated toevaland_runis called, then the per-call re-check (FR-L2-5) raisesEvalModeRealConnectorBlockedand 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/, whenps4 list --upstream fidoraruns from the CCS repo, then it prints fixture paths + ages for L1 + L1b fixtures only. L2 fixtures live in a separate repo; v1ps4 listdoes NOT enumerate them. To list L2 fixtures, an engineer runspython -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, whenps4 validate path/to/fixture.jsonruns, 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 withPS4_MODE=captureagainst an allowlisted host, then per-upstream redactor runs on each captured response, fixtures are written FIRST to a siblingtestdata/fixtures.pending/fidora/quarantine dir,ps4 validateruns against the pending files, and only validated files are moved to--output. Theps4-fixture-reviewPR 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 theps4-fixture-reviewlabel, 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_fromvalue. - AC-14 (Age warning): Given a PR build where any fixture’s
_meta.captured_atis 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-refreshGHA cron runs against opted-in upstreams and a fixture diverges from the live stage response, when the diff is computed, then:- The redactor MUST be applied to BOTH the committed fixture’s response AND the live captured response before diffing.
- 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. - Otherwise, on a non-empty diff, the cron opens a GitHub issue labeled
ps4-driftcontaining 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.
5. Solution Design
Section titled “5. Solution Design”5.1 Architecture
Section titled “5.1 Architecture”┌──────────────────────────── 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)5.2 Data model — fixture file format
Section titled “5.2 Data model — fixture file format”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, anyX-Request-Id/X-Correlation-Idshape. - Strip body fields per upstream normalizer: timestamps, nonces, request IDs.
- Neptune special:
parametersfield is a JSON-encoded STRING;nilparams 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})}//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}//go:build !ps4test
func installPS4(c *http.Client) *http.Client { return c } // production: no-op, PS4_MODE not even readCustom-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 stringconst ( 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 ps4testpackage 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.
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.gotype 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.pyfrom <connectors>.fetch_product_context import FetchProductContextTool, ProductContextArgsfrom <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(...).name5.6 Eval-mode isolation
Section titled “5.6 Eval-mode isolation”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 osfrom functools import wrapsfrom 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.
5.7 CLI
Section titled “5.7 CLI”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:
- Sets
PS4_MODE=capture,PS4_CAPTURE_QUARANTINE=<temp-dir>,PS4_CAPTURE_OUTPUT=<--output dir>in the child environment. - Shells out to
go test -tags ps4test -run <test_name>so the build-tag-gated PS4 transport is compiled in (FR-X-6). - The Transport (running inside the test process) writes captured fixtures to the quarantine dir (FR-L1-4 sub-bullet 5).
- After
go testexits,ps4 capturerunsps4 validateon every quarantined file. - 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-determinism | Canonical-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-5 | The 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 carry | ps4 validate’s diff check, the AC-15 drift-cron consumption |
testdata/fixtures/<upstream>/*.json — fixture files, hand-authored or ps4 capture-generated | The 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_schema | The __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 fixtures | Same 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 ps4testpackage 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.
5.8 Drift detection
Section titled “5.8 Drift detection”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 undertestdata/fixtures/<upstream>/(or its L1b/L2 equivalent) contains a top-levelresponse.bodyfield not listed inknown_fields/<upstream>.yml,ps4 validatefails 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 atinternal/testing/ps4/upstreams/<upstream>.go(theMaintainerfield inUpstreamRegistration, see §5.3). A smallcmd/ps4-maintainershelper compiles the registry and emits theupstream → teammap the cron reads — no single centralizedPS4_MAINTAINERS.ymlto 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.
6. Cross-Section Impact
Section titled “6. Cross-Section Impact”| Consumer | Impact | Notes |
|---|---|---|
| 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 Service | No 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. |
7. Dependencies
Section titled “7. Dependencies”| Spec | Direction | Notes |
|---|---|---|
| PS2 connector framework (PLT-680 / consumer-agent#286) | PS4 depends on | L2 mocks subclass PS2’s BaseTool interface; PS2 must merge first or change in lockstep |
| PS6 (004-ps6-bff-enrichment) | PS4 enables | PS6 enricher tests + conformance tests consume L1 fixtures |
| PC5 agent evals (PLT-683) | PS4 enables | Eval determinism at both layers is gated on PS4 |
| Q2 Verticals Spec Lab (S1a–S1g) | PS4 enables | Per-vertical L1 fixture sets + L2 mock subclasses |
| Parent epic | — | PLT-676 (Platform Spec Lab) |
8. Risks & Resolved Open Questions
Section titled “8. Risks & Resolved Open Questions”8.1 Decisions log
Section titled “8.1 Decisions log”This section is the canonical decisions record for the spec.
| Original OQ | Resolution |
|---|---|
| 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 upstreams | Neo4j — 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 mode | Three-layer defense: capture-source allowlist + automated per-upstream redactor + mandatory ps4-fixture-review PR label (CI lint gates) |
| OQ-5: Fixture authoring CLI scope | Minimal v1: ps4 list / validate / capture. No editor / TUI / diff in v1. |
| OQ-6: Fixture drift detection | Per-fixture captured_at + CI age warning (90 days, non-blocking) + opt-in weekly cron diff against stage (opens tracking issue) |
| OQ-7: PS2 seam | PS2 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. |
8.2 Risks
Section titled “8.2 Risks”| Risk | Severity | Mitigation |
|---|---|---|
| PS2 interface changes during review (consumer-agent#286 is draft) | Medium | L2 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 rules | High | Capture-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 triage | Medium | Weekly 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) | High | Per-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 PS4 | Medium | Phase 6 migration plan (§10.1) + lint rule banning httptest.Server in internal/clients/ after Phase 6 |
| L2 args_schema drift between mock and real connector | Medium | NFR-6 conformance test fails CI when schemas diverge |
| Capture mode used against prod despite allowlist (operator error) | High | Allowlist 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 key | Low | Capture 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 signals | Low | FR-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 diffs | High | AC-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 CI | Medium | Lint 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 it | Low | §5.4 documents the pattern is for SDK-wrapped upstreams only; raw-HTTP clients use L1 |
9. Testing Strategy
Section titled “9. Testing Strategy”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
replaywith 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 frominternal/testing/ps4/upstreams/<upstream>.goinit() calls per §5.3) and asserts: (a) everyUpstreamRegistrationhas non-nilNormalizer+Redactor, (b) every hostname ininternal/clients/<upstream>.goconfig has a matchingUpstreamRegistration, (c) the reverse — everyUpstreamRegistration.Hostnameresolves to a real client ininternal/clients/. Fails CI if any check fails. This replaces the simpler “enumerateinternal/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=evaland_is_mock=False, instantiating a real connector raisesEvalModeRealConnectorBlocked. - L2 eval guard — per-call re-check (AC-9b / FR-L2-5): given a real connector constructed under
PS4_MODE=replay, mutateos.environ["PS4_MODE"]="eval"post-init, then invoke_run(...); assertEvalModeRealConnectorBlockedraises on the call (not on init). Also assertimportlib.reloadof 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.
10. Rollout & Observability
Section titled “10. Rollout & Observability”10.1 Rollout
Section titled “10.1 Rollout”- Phase 1 — L1 mechanism + 3 upstreams (replay-only): PS4 RoundTripper lands as
internal/testing/ps4under 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. CLIlist+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 captureCLI lands.ps4-fixture-reviewPR-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-reviewlabel gate + CODEOWNERS rules for the L2 fixture path. Pythonps4 validateport (or wrapped Go invocation) lands. - Phase 5 — Eval-mode wiring + PC5 integration:
PS4_MODE=evalwired into PC5 eval runner. Eval-mode metrics surfaced. PC5 eval suite migrated to PS4 fixtures. - Phase 6 — Migration + cleanup: Existing per-test
httptest.Serverstubs ininternal/clients/migrated to PS4 fixtures. Lint rule banninghttptest.Serverininternal/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.
10.2 Observability
Section titled “10.2 Observability”| Metric | Labels | Meaning |
|---|---|---|
ps4.fixture.requests_total | layer, 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_total | layer, upstream (L1) or connector (L2) | MUST be 0 in healthy eval runs; non-zero = misconfiguration alert |
ps4.fixture.capture_redacted_fields_total | upstream, field | What the redactor removed; spike = upstream schema change or new PII pattern |
ps4.fixture.age_days | upstream, fixture_key (sampled / coarse) | Drift signal; correlates with cron findings |
ps4.drift_cron.diffs_opened_total | upstream | Weekly 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).
11. Appendix
Section titled “11. Appendix”A. References
Section titled “A. References”- Ticket: PLT-688
- Parent epic: PLT-676
- Platform Spec Lab: Confluence — Services row PS4
- PS2 (connector framework): PLT-680 — consumer-agent#286
- PS6 (BFF enrichment): PLT-681 — consumer-context-service#77
- PS1 (knowledge graph): consumer-graph-worker#161
- PC5 (agent evals): PLT-683
- Brainstorm decisions log:
~/src/doc/plt-688-mock-proxy/superpowers/2026-05-21-ps4-design-decisions.md
B. CCS upstream surface
Section titled “B. CCS upstream surface”See §3.1 table. Source: internal/clients/ directory listing, CLAUDE.md “Upstream Service URL Patterns”.
C. Neptune reference client
Section titled “C. Neptune reference client”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).