Skip to content

PS6 — Domain Object Enrichment & BFF Assembly

PS6 — Domain Object Enrichment & BFF Assembly

Section titled “PS6 — Domain Object Enrichment & BFF Assembly”

The Labs agent stack and the consumer mobile app need to assemble enriched response objects keyed on a domain identifier — Rich Cards, retailer cards, offer details, weekly forecast, repurchase nudges. Today this work happens inside consumer-context-service (CCS) as a cluster of per-domain services that each invented their own:

  1. Response shape. internal/products, internal/offers, internal/users, internal/shopping, internal/context each have their own DTOs. Multiple consumers (the consumer-agent via REST/MCP + the DM pipeline that delivers via Notification Service) read these shapes and can’t tell — without inspecting each one separately — whether a partial failure happened, whether the data was cached, how stale it is, what version of the enricher produced it.
  2. Caching policy. Per-domain TTL caches with no observable common contract. External teams adopting CCS have to rediscover each domain’s caching model.
  3. Partial-failure semantics. A failed Button call in product enrichment vs a failed NELI call in offer eligibility surface differently — sometimes as missing fields, sometimes as a degraded response, sometimes as a 5xx. No declared policy.

The Q2 Verticals Spec Lab S1 epic (Context Verticals — Frank’s epic lead) has shipped two verticals (S1a Rewards, S1c Shop) and has five drafts (S1b Play, S1d eReceipts, S1e Offer Details, S1f Restaurant, S1g Retailer). PLT-446 (Offer Enrichment Pipeline, replacing the discover-cache dependency) is the first concrete in-flight enricher whose shape this spec generalizes.

Without this spec, three things go wrong:

  1. Every new enricher (S1b/d/e/f/g + future Labs vertical work) reinvents the contract. No envelope, no partial-failure model, no caching policy declaration.
  2. The consumer-agent has no typed response shape from CCS. Each consumer-agent tool/endpoint pair invents its own response handling — bespoke partial-failure logic, ad-hoc cache awareness, no version signal.
  3. The Notification Service has no anchor for what a “DM payload” is. Notification Service receives whatever the DM-pipeline scheduler hands it; without an envelope contract, it can’t deterministically suppress sends on degraded enricher output.

This spec establishes:

  • The single-enricher contract (input + envelope output + caching + partial-failure + observability).
  • The universal envelope and the typed per-domain payload registry.
  • The consumer-agent contract: CCS returns the envelope as the REST/MCP response body; the agent unwraps payload using domain_type + enricher_id.
  • The Notification Service contract: NS reads the envelope (off the DM-pipeline queue or via direct CCS call), suppresses on status="error", transforms payload into the notification body via per-domain transformer.
  • The v1 extraction rule from S1a + S1c shipped + PLT-446 in flight.
  • The conformance rule for new verticals (S1b/d/e/f/g declare conformance from day one).

From the Platform Spec Lab Confluence: “PS6 — Domain Object Enrichment & BFF Assembly. Takes a domain identifier (user, offer, retailer, product, receipt, restaurant) and assembles the enriched BFF response object that streams to mobile. Each vertical’s enricher is a vertical-spec concern; this spec defines the platform contract — input shape, enrichment pipeline, output schema, caching, partial-failure semantics. Powers Rich Cards, retailer cards, offer details, weekly forecast, repurchase nudges, and every S1 vertical.”

PS6 restates these capabilities as concrete, measurable requirements in §4. The capabilities_section in frontmatter is "derived (BFF assembly for mobile-delivered responses)" per Confluence’s note — no canonical § exists; this spec is the canonical reference for the Labs stack.

consumer-context-service (Go service, AWS ECS): Aggregates 12+ upstream Fetch services. Single binary, two transports (REST :8080, MCP :8081). Both transports call the same domain services — transports are thin adapters with zero business logic.

Domain layout (today’s de-facto enrichers):

DomainWhat it doesClosest to PS6 contract?
internal/productsProduct metadata (FPS), retailer-specific pricing (FIDORA), merchant data (Button)Yes — multi-source, typed output
internal/offersOffer search (semantic), eligibility (NELI), details (Offer Guardian)Yes — multi-source, typed output
internal/usersPurchase history, repurchase candidates (Neo4j), graph recommendationsYes — but reads PS1 directly (legacy path)
internal/shoppingLIDAR (location-based retailers), eReceipt support checkingYes
internal/contextCross-domain orchestrator composing all of the aboveCloser to S5 (vertical assembler); not a PS6 enricher in the strict sense
internal/cacheGeneric TTL cacheInfrastructure used by all enrichers

Already-shipped enrichers (Q2 Spec Lab S1 epic):

  • S1a Rewards / PointPass Context ✅ — owned by Frank’s S1a sub-spec; lives in internal/users enrichment with PointPass-specific shape.
  • S1c Shop Context ✅ — owned by Prakash’s S1c sub-spec; multi-step retailer/offer/cart logic.

In-flight (forms PS6 v1 contract together with S1a/S1c):

  • PLT-446 Offer Enrichment Pipeline — replaces the discover-cache dependency for offers. First concrete enricher under PS6’s contract.

Draft sub-specs that will declare PS6 conformance from day one (per W5):

  • S1b Play, S1d eReceipts, S1e Offer Details, S1f Restaurant, S1g Retailer — all in the Q2 Verticals Spec Lab.
DecisionRationaleSource
PS6 = consumer-context-serviceThis spec lives in the CCS repo; CCS is the BFF home in the Labs stackSelf-evident (spec is checked into CCS)
PS6 specifies the single-enricher contract onlyMulti-enricher orchestration belongs to consumer-agent (request-level composition) and S5 (Shop-vertical assembler in internal/context); PS6 staying narrow keeps adoption tractable for new verticalsBrainstorm Q3 (working assumption W6)
Pipeline internals are black-boxS1a (read-time aggregation) and S1c (multi-step retailer/offer) have very different shapes; forcing a prescribed pipeline retrofits them with no contract-level payoffBrainstorm Q4 (working assumption W7)
Output = universal envelope + typed per-domain payloadThe envelope is where partial-failure/caching/observability/versioning live; the typed payload preserves vertical type safetyBrainstorm Q5 (working assumption W8)
Downstream consumers (consumer-agent, Notification Service) receive the envelope as-isBoth transports (REST/MCP for the agent; DM-pipeline queue for NS) carry EnricherResponse[T]. NS suppresses on status="error"; consumer-agent renders payload per domainThis spec, §6
v1 contract extracted from S1a + S1c + PLT-446; new verticals conform from day oneThree differently-shaped enrichers give the v1 contract some generality; conformance is cheaper than retrofittingBrainstorm Q7 (working assumption W5)
TermDefinition
EnricherA single-domain function that takes an EnricherRequest and returns a EnricherResponse[T] envelope; the unit of work PS6 specifies
Domain identifierA (domain_type, domain_id) pair — e.g., ("offer", "off_xyz"), ("user", "u_abc")
EnvelopeThe universal wrapper every enricher returns: {enricher_id, domain_type, principal, version, status, partial[], cache_meta, timing, payload: <T>}
PayloadThe typed, per-domain inner shape inside the envelope (RewardsPayload, ShopPayload, OfferPayload, etc.)
PrincipalThe on-behalf-of identity (typically a user_id) the payload was assembled for; stamped into the envelope and verified by downstream consumers (consumer-agent, Notification Service) before forwarding/delivery
Critical sourceA failed FailedSource entry with critical=true; forces envelope status="error" and downstream suppression
CacheableWhether a response is permitted to be cached at all (false for status="error" and per-enricher policy); declared in cache_meta.cacheable
Partial responseAn envelope with status="partial" and at least one partial[] entry, none of which are critical — some sources failed but the enricher still has useful data
Domain-payload registryThe list of canonical (domain_type, enricher_id) entries supported by PS6 and their payload schemas; lives in internal/registry/, with one file per enricher at internal/registry/{domain_type}/{enricher_id}.go (see §5.4 “Registry layout”). Each per-enricher file registers itself via init(); no shared manifest to edit.
lock_screen_safePer-leaf-field boolean tag on payload fields; declared once by the vertical author. Notification Service filters its notification body to lock_screen_safe=true fields. Other channels (consumer-agent) receive the envelope unfiltered, because the envelope contains external-facing data only (see “Internals sidecar”).
Internals sidecarOptional second enricher method (EnrichInternals()) returning a typed Internals[I] struct alongside the envelope. Carries assembler-only data (internal scoring, internal IDs, debug context) used by S5 in-process. Never serialized to any transport (REST / MCP / DM-pipeline queue). Eliminates the per-channel-allowlist failure mode by ensuring internal data never enters the envelope.
ConformanceA vertical’s enricher returns the universal envelope, declares its caching policy + per-leaf-field lock_screen_safe tags, expresses partial failures via status + partial[], emits required observability spans. If the enricher needs to surface internal-only data to S5, it MUST do so via the Internals sidecar — not via envelope fields.
  1. Every PS6 enricher MUST return the universal envelope (EnricherResponse[T]). No enricher returns a bare domain DTO. The envelope schema is pinned in §5.2.
  2. The payload field of the envelope MUST be typed per (domain_type, enricher_id) registry entry. Multiple enrichers MAY share the same domain_type if they declare distinct enricher_id values and distinct payload shapes (e.g., (user, rewards) and (user, shop)). The supported domain_type set is {user, offer, retailer, product, receipt, restaurant, play} for v1; net-new domain_type values require both a registry entry AND at least one (domain_type, enricher_id) payload schema.
  3. Pipeline internals are black-box. PS6 specifies the boundary (input shape, output envelope, caching declaration, partial-failure declaration, observability hooks). PS6 does NOT prescribe pipeline stages, retry shape, or fan-out mechanics.
  4. Enrichers MUST declare their caching policy in cache_meta on every response. cache_meta.cacheable=false (with ttl_seconds=null, cached=false) for responses that MUST NOT be cached — including status=error responses and per-enricher non-cacheable cases. cacheable=true with cached=false means “this response is cacheable but wasn’t served from cache (cache miss).” cached=true requires cacheable=true. When the enricher’s payload depends on the calling actor or on-behalf-of principal (i.e., per-user data, eligibility-filtered, or any actor-scoped result), the cache key MUST include principal. Caching keyed only on domain_id is a defect for actor-scoped payloads — risks cross-tenant cache pollution.
  5. Enrichers MUST express failed sources via envelope.partial[] (list of FailedSource with source, reason, detail, critical). When ANY entry in partial[] has critical=true, the envelope’s status MUST be error (not partial); critical failures cannot be downgraded to a partial response. When partial[] is non-empty and no entry is critical, status="partial". When partial[] is empty, status="ok". No silent degradation; no mixing of status="ok" with non-empty partial[].
  6. Enrichers MUST emit a minimum observability set: a span named enricher.{enricher_id} with domain_type, domain_id_hash, status, cache_hit, version, principal_hash attributes; metrics ps6.enricher.duration_seconds{enricher_id, status}, ps6.enricher.requests_total{enricher_id, status, cache_hit}; structured logs with enricher_id, domain_type, domain_id_hash, principal_hash, status, correlation_id, version. Domain IDs and principals MUST be hashed (SHA-256 hex, first 16 chars) before emission to spans/logs/metrics — raw values stay only inside the data tier. Un-hashed lookups for debugging are accessed via a Labs-security-reviewed audit path, not via routine observability.
  7. The envelope’s version field MUST be set on every response. New payload schemas MUST bump the version following semver: minor for additive changes, major for breaking.
  8. Every PS6 enricher MUST emit the universal envelope shape on REST, MCP, and any other transport — i.e., enrichers do NOT separately structure responses per consumer. The envelope is the contract; consumers (consumer-agent, Notification Service) parse the envelope and act on its fields.
  9. Enrichers MUST be idempotent over the semantic envelope fields (enricher_id, domain_type, principal, version, status, payload) under the same (domain_type, domain_id, principal, context_vars) input — the same call returns the same semantic envelope (within cache TTL). The envelope’s volatile fields — cache_meta.cached, timing — MAY differ between calls. Required because Notification Service delivery is at-least-once and downstream consumers (consumer-agent re-fetch on retry, Notification Service dedup) rely on payload idempotency. Idempotency is scoped to a single rollout state. During a §5.4 deprecation window, version MAY flip between old and new across calls as the new schema rolls out; downstream dedup designers (Notification Service) MUST key on (enricher_id, domain_type, principal, payload_hash) if dedup must survive a version transition, not on version itself.
  10. New verticals (S1b Play, S1d eReceipts, S1e Offer Details, S1f Restaurant, S1g Retailer) MUST declare PS6 conformance from day one — each vertical’s spec MUST cite this PS6 spec and pin its domain_type, enricher_id, payload schema, caching policy, partial-failure shape, and per-field lock_screen_safe tags (§5.2). If the vertical needs to surface internal-only data to S5, the spec MUST specify the Internals sidecar struct shape (§5.2).
  11. The envelope MUST carry a principal field identifying the on-behalf-of user (or service) the payload was assembled for. Downstream consumers MUST verify envelope.principal matches the request target before forwarding/delivery — concretely: consumer-agent MUST verify the envelope’s principal matches the agent’s invoking user before rendering payload to that user; Notification Service MUST verify the envelope’s principal matches the notification recipient before delivery. Principal mismatch MUST be treated as a security incident (suppress + alert, emit ps6.envelope.principal_mismatch_total{consumer=...}), not a recoverable error.
  12. FailedSource.detail and cache_meta.key MUST NOT contain stack traces, internal hostnames or IPs, raw upstream error bodies, SQL/Cypher fragments, file paths, or internal request IDs. detail MUST be ≤200 chars and SHOULD draw from a controlled reason enum where possible; when free text is needed, enrichers MUST sanitize before populating. cache_meta.key MUST be an opaque token (hash-prefixed or random-padded) that does NOT reveal the internal cache key structure. Two-layer enforcement (defense-in-depth): CI lint MUST verify these shapes statically against the codebase (primary control); runtime MUST run a redactor at envelope-construction time that fails-closed by replacing offending fields with "redacted" and incrementing ps6.envelope.detail_redacted_total (defense-in-depth — catches dynamic strings lint cannot model).
  1. Enricher p95 latency MUST be < 500ms for cache-hit responses, < 2s for cache-miss responses with full upstream fan-out. Per-enricher SLOs MAY be tighter but MUST NOT be looser without §8 risk acknowledgment.
  2. The envelope MUST add < 512 bytes (uncompressed) of overhead per response on top of the payload size when status=ok and partial=[]. Worst-case envelope size MUST be bounded — partial[] capped at 16 entries, each FailedSource.detail capped at 200 chars, timing.upstream_ms map capped at 32 entries. Compression MAY further reduce wire size but is not a contract.
  3. Partial-response handling MUST add < 50ms overhead vs the equivalent fully-successful response.
  4. Schema documentation drift between the registry artifact and actual payload shapes MUST be caught by CI.
  5. Adding a new (domain_type, enricher_id) entry MUST require creating internal/registry/{domain_type}/{enricher_id}.go (with init() registration), the registry artifact, and a payload schema test fixture in the same PR. No shared manifest file is edited — per-enricher files eliminate cross-vertical merge conflicts when multiple verticals declare conformance simultaneously.
  • AC-1: Given an enricher invocation with valid (domain_type, domain_id, context_vars), when the enricher succeeds with all sources, then the envelope has status="ok", partial=[], cache_meta.cached set (true or false), payload populated per the domain registry.
  • AC-2: Given an enricher invocation where one non-critical upstream source fails (e.g., NELI 503 on a path where eligibility is best-effort), when the enricher continues with degraded data, then the envelope has status="partial", partial[] includes {source: "neli", reason: "upstream_unavailable", critical: false}, payload is populated with the available data; no exception leaks to the caller. (When the same source’s failure is declared critical, AC-5 + FR-5 require status="error" instead.)
  • AC-3: Given an enricher with caching enabled and two consecutive identical requests, when the second request hits within the declared TTL, then the second response has cache_meta.cached=true and timing reflects the cache-hit latency (not the upstream-fan-out latency).
  • AC-4: Given a manifest change adding a new domain_type (“invoice”), when the registry is not updated in the same PR, then CI fails (drift check, NFR-4).
  • AC-5: Given an envelope with status="error" (because at least one FailedSource has critical=true), when the Notification Service reads the envelope to gate notification send, then NS suppresses the notification deterministically based solely on the envelope (no NS-side custom unwrapping or per-enricher critical-source list) and emits ps6.notification.suppressed_critical_failure_total{enricher_id, source}.
  • AC-6: Given a PS6 enricher invocation regardless of transport (REST or MCP), when the response is returned, then the response body parses cleanly as EnricherResponse[T] using the registry to resolve T for the (domain_type, enricher_id) pair.
  • AC-7: Given a new vertical spec (S1b Play) declaring PS6 conformance, when its enricher implementation is added at internal/registry/{domain_type}/{enricher_id}.go, then the contract conformance runner (in internal/registry/) discovers the new file via directory glob and verifies envelope shape, caching declaration (cache_meta.cacheable + key shape), partial-failure shape, observability hooks, and per-payload PII tagging (see §5.2 “PII model”) against the declared (domain_type, enricher_id) pair — independently from any other vertical’s file.
  • AC-8: Given an envelope with principal=alice and a downstream target identified as bob, when the consumer-agent receives the envelope (or Notification Service reads it from the DM-pipeline queue), then forwarding/delivery MUST be suppressed AND ps6.envelope.principal_mismatch_total{consumer=...} MUST increment. Principal mismatch is a security incident.
  • AC-9: Given an enricher invocation that returns status=error, when the response is constructed, then cache_meta.cacheable=false, cache_meta.ttl_seconds=null, cache_meta.cached=false. Error responses MUST never be cached.
  • AC-10: Given an EnricherRequest with missing principal, when the enricher is invoked, then it returns 400 Bad Request with structured error code missing_principal; no upstream calls are made and no cache key is constructed.
  • AC-11a (CI lint): Given a PR introducing or modifying enricher code, when CI runs, then a static lint check fails the PR if any FailedSource.detail source location can produce stack frames, internal hostnames, Cypher fragments, or absolute filesystem paths under any reachable execution path.
  • AC-11b (runtime redactor): Given a FailedSource.detail field at envelope-construction time containing any of: a stack frame regex, internal hostname pattern, Cypher fragment, absolute filesystem path, when the runtime redactor (mandatory per FR-12) inspects the field, then it replaces the field with "redacted" and increments ps6.envelope.detail_redacted_total{enricher_id}. Runtime redaction is fail-closed defense-in-depth; CI lint is the primary control.
  • AC-12: Given an envelope reaching the Notification Service notification-body transformer with payload fields tagged lock_screen_safe=true and lock_screen_safe=false, when NS composes the notification body, then only lock_screen_safe=true fields appear in the body and lock_screen_safe=false fields are dropped. consumer-agent (REST/MCP) does NOT filter — envelope payload is external-facing by §5.2’s design contract. Additionally: given an enricher implementing EnrichInternals(), when CI lint inspects transport adapter packages (internal/api, internal/mcp, NS-side transformer code), then no import of the enricher’s Internals[I] type is permitted; violation fails the build.
PS6 enricher boundary
│ EnricherResponse[T]
│ (universal envelope)
┌───────────────────────────────────────────────────────────┐
│ Per-enricher implementation (black-box internals) │
│ │
│ Read PS1 (graph) │
│ Call upstreams (FPS, FIDORA, Button, NELI, ...) │
│ Apply business logic / scoring │
│ Cache (per-enricher TTL or distributed) │
│ Assemble payload │
│ Wrap in envelope with status / partial[] / cache_meta │
└────────────────────────────┬──────────────────────────────┘
│ envelope
┌───────────────────────────────────────────────────────────┐
│ Consumer pathways │
│ │
│ consumer-agent (REST/MCP) → envelope returned as body │
│ Notification Service → envelope drives notification body │
│ Direct REST/MCP → envelope returned as response body │
│ S5 vertical assembler → envelope as input to assembly │
└───────────────────────────────────────────────────────────┘

PS6 owns the boundary. The black-box internals are implementation choices each enricher (vertical) makes.

Universal envelope (Go shape; pinned in internal/registry/envelope.go, shared across every per-enricher file):

// EnricherResponse is the universal envelope every PS6 enricher returns.
type EnricherResponse[T any] struct {
EnricherID string `json:"enricher_id"` // e.g., "rewards", "shop", "offer_enrichment"
DomainType string `json:"domain_type"` // e.g., "user", "offer"; required for typing in transports that don't carry domain_type out-of-band (DM-pipeline queue, MCP tool result)
Principal string `json:"principal"` // on-behalf-of identity (typically a user_id) the payload was assembled for; consumers MUST verify this matches the streaming/delivery target before forwarding
Version string `json:"version"` // semver — payload schema version
Status EnvelopeStatus `json:"status"` // "ok" | "partial" | "error"
Partial []FailedSource `json:"partial"` // populated when sources failed; envelope status determines severity
CacheMeta CacheMeta `json:"cache_meta"` // declared caching policy + cache state
Timing TimingInfo `json:"timing"` // latency breakdown
Payload T `json:"payload"` // typed per (domain_type, enricher_id) registry entry
}
type EnvelopeStatus string // "ok" | "partial" | "error"
type FailedSource struct {
Source string `json:"source"` // upstream service name (e.g., "neli", "fidora", "ps1")
Reason string `json:"reason"` // controlled enum; mapped from PS1 §5.3.1 taxonomy for ps1-source failures
Detail string `json:"detail"` // bounded sanitized string (≤200 chars); see §5.4 leakage prevention
Critical bool `json:"critical"` // if true, envelope.status MUST be "error" and downstream consumers MUST suppress (Notification Service) or mark untrusted (consumer-agent)
}
type CacheMeta struct {
Cached bool `json:"cached"` // was this response served from cache?
Cacheable bool `json:"cacheable"` // is this response cacheable at all? (false for status=error or per-enricher policy)
TTLSeconds *int `json:"ttl_seconds"` // present iff cacheable=true; remaining TTL if cached, full TTL if cache miss; null iff cacheable=false
Key *string `json:"key"` // opaque token (hash-prefixed or random-padded; see §5.4 leakage prevention); present iff cached=true
}
type TimingInfo struct {
TotalMs int `json:"total_ms"`
UpstreamMs map[string]int `json:"upstream_ms"` // per upstream; capped at 32 entries
EnricherMs int `json:"enricher_ms"` // time inside enricher (post-upstream)
}

Domain payload registry (the supported T types for v1):

The registry is keyed on (domain_type, enricher_id). Multiple enrichers MAY share a domain_type if they declare distinct enricher_id values and distinct payload shapes. Consumers select the enricher (and therefore the payload type) by enricher_id, not by domain_type alone.

(domain_type, enricher_id)Go typeSource vertical / specStatus
(user, rewards)RewardsPayloadS1a Rewards ✅Shipped; wraps in envelope per Phase 2 of rollout
(user, shop)ShopPayloadS1c Shop ✅Shipped; same
(offer, offer_enrichment)OfferPayloadPLT-446 (in flight)First born-conformant enricher
(retailer, retailer_context)RetailerPayloadS1g Retailer (draft)Born conformant per W5
(product, offer_details)ProductPayloadS1e Offer Details (draft)Born conformant per W5
(receipt, ereceipts)ReceiptPayloadS1d eReceipts (draft)Born conformant per W5
(restaurant, restaurant_network)RestaurantPayloadS1f Restaurant Network (draft)Born conformant per W5
(play, play_context)PlayPayloadS1b Play (draft)Born conformant per W5

enricher_id values shown above are placeholders pending each vertical’s own naming choice — verticals MUST declare an enricher_id at conformance time and MUST NOT collide with any existing entry.

PII model (envelope = external-facing; internal data via sidecar).

The envelope (and therefore payload: T) carries external-facing data only. Anything in payload is exposable to any external channel that receives the envelope (consumer-agent for client rendering; Notification Service for delivery, subject to the lock-screen filter below). Internal-only data — internal scoring, internal IDs, debug context — MUST NOT live in the envelope.

This inverts the design from an output-side filter (“classify per field, allowlist per channel, strip at serialization”) to an entry-side guarantee (“internal data never enters the envelope, so it cannot leak through it”). The earlier 4-class scheme (none / pii_low / pii_high / internal) and the per-channel allowlist are removed; type honesty is restored because the same T reaches every external consumer.

Per-leaf-field tag (required for v1). Each registered (domain_type, enricher_id) payload schema MUST declare, per leaf field:

  • lock_screen_safe: bool — whether this field is safe to render on a device lock screen (no PII, no eligibility-sensitive content, no message that could mislead if seen without context).

Notification Service, before composing the notification body, MUST drop any field whose lock_screen_safe=false. Other channels (consumer-agent) receive the envelope unfiltered — the envelope’s contents are external-by-definition.

Internals sidecar (for S5 / in-process callers).

When an enricher needs to surface assembler-only data (internal scoring inputs, internal IDs, S5-specific debug context), it MUST do so via a separate method, not via envelope fields:

// Optional companion to Enrich(). Only in-process callers (S5) invoke this.
// The Internals[I] struct has zero transport surface — no JSON/MCP marshaling, no
// DM-pipeline queue serialization. CI lint MUST verify Internals types are not
// referenced from any transport adapter package.
type EnricherInternals[T, I any] interface {
Enricher[T] // returns EnricherResponse[T]
EnrichInternals(ctx context.Context, req EnricherRequest) (EnricherResponse[T], Internals[I], error)
}
type Internals[I any] struct {
EnricherID string `json:"-"` // never serialized
Principal string `json:"-"`
Detail I `json:"-"` // assembler-only payload
}

If a vertical has no S5-internal data, it implements only Enrich() (the base interface) and does not register an Internals shape. The sidecar is opt-in.

Why this shape (vs the previous per-channel-allowlist design):

  1. Type honesty. payload: T is one shape that reaches every external consumer — no per-channel polymorphism.
  2. No “leak by adding a field” failure mode. Internal data physically isn’t in the envelope; a vertical author adding a new field to Payload is adding an external-facing field by construction. The previous spec called out this failure mode (vertical authors leak PII by adding fields without the right allowlist); the inversion eliminates it.
  3. Lock-screen filtering is one bit, one place. NS owns the lock-screen concern, not every vertical author.

Conformance test (§5.4) MUST verify:

  • (a) Every leaf field on payload has a declared lock_screen_safe tag.
  • (b) NS-bound serialization path drops lock_screen_safe=false fields (test fixture: invoke enricher, route through NS-side transformer, assert output contains only true-tagged fields).
  • (c) For enrichers that implement EnrichInternals(), CI lint verifies the Internals[I] type is not imported from any transport adapter package (internal/api, internal/mcp, NS-side packages).

PS6 v1 reverse-engineers the contract from S1a (Rewards, shipped), S1c (Shop, shipped), and PLT-446 (Offer Enrichment, in flight). Each envelope/payload field traces to one of these:

Envelope / contract fieldOriginNotes
enricher_idS1c (multiple Shop sub-services have distinct identifiers in CCS)Naming convention follows CCS internal package names
domain_typeS1a (Rewards is per-user) + PLT-446 (per-offer)Distinct domain_type values needed when one identifier shape (user_id) maps to multiple specialized enrichers
versionPLT-446 (replacement for discover-cache; needed schema versioning to stage rollout)Semver; minor for additive, major for breaking
status enum (ok, partial, error)S1c (NELI failures don’t kill the response; existing distinction between “degraded but useful” and “fully broken”)Critical-source mechanism (FR-5) makes degraded → suppress deterministic
partial[] shape (source/reason/detail/critical)S1c (NELI eligibility failures + FIDO Search timeouts already surface per-source)critical field is new in PS6 v1 — generalizes existing ad-hoc Notification Service suppression rules
cache_meta (cached/cacheable/ttl_seconds/key)S1a (60s TTL on user_id) + PLT-446 (5min TTL on (offer_id, user_id))cacheable=false for error responses (AC-9) is new in PS6 v1
timing (per-upstream + total)S1c (multi-step pipelines already track per-step latency for debugging)Capped at 32 entries per NFR-2
principal fieldNew in PS6 v1 (security gap from PR #2 review of Ali’s specs)Closes the cross-section authz hole between PS6 and downstream consumers (consumer-agent, Notification Service); FR-11
Per-field lock_screen_safe tag + Internals sidecarNew in PS6 v1 (security gap from self-review; design refined post-review #1)Envelope payload is external-facing only; per-leaf lock_screen_safe boolean drives NS notification-body filter; internal-only data lives in the optional EnrichInternals() sidecar with zero transport surface
Black-box pipeline (no prescribed stages)S1a (single-step) + S1c (multi-step) deliberately differFR-3; verticals choose pipeline shape

S1b/d/e/f/g (drafts) declare conformance against this v1 contract from day one (FR-10) — no retrofit work for them.

type EnricherRequest struct {
DomainType string `json:"domain_type"` // required; one of the registered values
DomainID string `json:"domain_id"` // required; opaque identifier scoped to domain_type; max 256 bytes
EnricherID string `json:"enricher_id"` // required when multiple enrichers share domain_type; otherwise inferred from endpoint path
ContextVars map[string]any `json:"context_vars"` // optional; per-enricher schema; max 4 KB JSON; sanitized & validated by enricher
Principal string `json:"principal"` // required; on-behalf-of identity; used for authz + cache keying
RequestID string `json:"request_id"` // required; for idempotency tracking + observability correlation
}

Validation requirements (RFC 2119, normative; mismatch returns the indicated HTTP code with a structured error body):

  • Enricher MUST return 400 Bad Request with error code missing_field when any required field (domain_type, domain_id, principal, request_id, plus enricher_id when not inferred) is absent. AC-10 covers principal-absent specifically.
  • Enricher MUST return 400 Bad Request with error code invalid_domain_type when domain_type is not in the registry.
  • Enricher MUST return 403 Forbidden with error code principal_mismatch when the supplied principal cannot be authenticated against caller credentials (e.g., on-behalf-of authorization fails).
  • domain_id MUST be ≤256 bytes; longer values return 400 with error code domain_id_too_large.
  • context_vars MUST be ≤4 KB JSON; longer values return 400 with error code context_vars_too_large. Per-enricher tighter limits MAY apply (declared in registry entry).
  • Enricher MUST NOT make any upstream calls or construct a cache key when validation fails — validation is gating.

PS6 doesn’t add new HTTP endpoints for the enricher boundary — the existing CCS REST and MCP transports already expose per-domain endpoints. PS6’s contract is on the response shape, not on new endpoints.

REST surface (today’s CCS endpoints, with envelope wrapping):

Today’s internal/api handlers return per-domain DTOs. Phase 2 of rollout (§10.1) wraps every handler’s response in EnricherResponse[T]. Existing endpoints retain their paths and query params; only the body shape changes.

MCP surface (today’s CCS MCP tools, with envelope wrapping):

Each MCP tool returns the envelope. Tool definitions in internal/mcp are updated to declare the envelope shape. MCP credential lifecycle for the CCS MCP transport inherits PS1 §5.3.2 verbatim — same scope/TTL/revocation/rotation/caller-binding requirements apply. PS6 does not duplicate the protocol; PS6 enrichers reachable via MCP are subject to the same per-actor authz that PS1 §5.3.2 mandates.

Cross-section interfaces:

  • consumer-agent (Labs agent, REST/MCP consumer): receives EnricherResponse[T] as the response body. The agent unwraps payload using the envelope’s own domain_type + enricher_id (the registry resolves T). The agent verifies envelope.principal matches the user it’s serving before rendering (FR-11).
  • Notification Service (DM delivery): reads EnricherResponse[T] from the DM-pipeline queue (or via direct CCS call), suppresses send when envelope.status="error" (which by FR-5 includes any envelope where any partial[] entry has critical=true); transforms payload into notification body via per-domain transformer. NS does NOT separately inspect partial[] for criticality — status="error" is the single decision boundary. NS verifies envelope.principal matches the notification recipient before delivery (FR-11).
  • S5 (vertical assembler): S5 calls multiple PS6 enrichers, receives multiple envelopes, composes a ShoppingReply-shaped output. PS6 doesn’t constrain S5’s composition logic.

Black-box pipeline mechanics:

Each enricher chooses its own pipeline. Concrete shapes today:

  • S1a Rewards — single-step read-time aggregation: query Neo4j for points + recent earnings, format. Cache: 60s TTL on (user_id) key.
  • S1c Shop — multi-step: retailer lookup (LIDAR) → offer eligibility (NELI) → ranking → cart enrichment. Cache: per-step.
  • PLT-446 Offer Enrichment — single-domain enrichment for offer type: Offer Guardian + NELI + FIDO Search. Cache: 5min TTL on (offer_id, user_id) key.

PS6 doesn’t prescribe these. New enrichers pick their own pipelines.

Caching contract:

Caching is per-enricher (no shared cache infrastructure in v1). The envelope’s cache_meta declares state (cached: bool, ttl_seconds, key) so consumers can:

  • Decide whether to retry on a stale cached response (e.g., Notification Service may suppress sends if cached=true and ttl_seconds indicates stale data).
  • Observe cache hit rates per enricher.

Partial-failure shape:

partial[] is a list. Each entry is a FailedSource with source, reason, detail, critical. reason values are drawn from PS1’s error taxonomy (see PS1 §5.3.1) for source="ps1" failures; for other upstreams, enrichers map their failures to the same controlled enum (upstream_unavailable / upstream_timeout / upstream_partial / unauthorized / invalid_request). The envelope’s payload contains whatever data the enricher could assemble. Examples:

  • S1c Shop, NELI failed (non-critical: eligibility is best-effort): status="partial", partial=[{source:"neli", reason:"upstream_unavailable", critical:false}], payload.offers is empty (eligibility unknown), other payload fields populated.
  • PLT-446 Offer, Offer Guardian failed (critical: offer details are core): partial=[{source:"offer_guardian", reason:"upstream_unavailable", critical:true}], FR-5 forces status="error", payload is the zero value of OfferPayload.

The convention: status="partial" means usable degraded data; status="error" means the response is not safe to act on (forced by any critical source per FR-5). Notification Service suppresses on status="error" only.

Versioning:

envelope.version follows semver per payload schema:

  • Minor bump: additive (new optional field, new union variant).
  • Major bump: breaking (rename, type change, semantic shift).

Major bumps require a deprecation window with the following protocol (mirrored verbatim from PS1 §5.4 to guarantee PS1↔PS6 consistency):

  1. PR introduces new payload schema; old schema marked deprecated.
  2. Reads of the old schema continue working; metric ps6.envelope.deprecated_field_use_total{enricher_id, field} increments per call.
  3. After 30 days minimum AND zero increments to the metric for 7 consecutive days, the old schema is removed in a follow-up PR with a new major version bump.
  4. Removal PR includes the metric history showing zero use and links the original deprecation PR.

Security-driven schema changes carve-out (mirrored from PS1 §5.4). When a schema change is motivated by tightening authorization on a payload field — redacting a value for certain channels, narrowing visible fields, adding a permission check — the parallel-exposure path MUST NOT be used. The old schema is removed atomically with the new one. Labs security review signs off on the carve-out. Reason: parallel exposure during deprecation is an authz-bypass window; consumers of the unprotected schema continue reading through it.

Envelope leakage prevention:

FailedSource.detail and cache_meta.key are externally visible — returned to consumer-agent (and thereby rendered to clients), delivered via Notification Service in notifications. To prevent leakage of internal information (per FR-12):

  • detail MUST NOT contain stack traces, internal hostnames or IPs, raw upstream error bodies, SQL/Cypher fragments, file paths, or internal request IDs.
  • detail MUST be ≤200 chars (consistent with NFR-2), drawn from a controlled enum of reason values where possible. When free text is needed, enrichers MUST sanitize before populating.
  • cache_meta.key MUST be an opaque token (hash-prefixed or random-padded) that does NOT reveal the internal cache key structure. Raw cache keys (e.g., user:{user_id}:rewards:v2) are a defect — they leak both the key shape and any embedded identifiers.
  • Two-layer enforcement (defense-in-depth): CI lint MUST verify these shapes statically (catches the easy patterns; AC-11a). Runtime MUST run a redactor at envelope-construction time that pattern-matches the same blocklist and replaces offending values with "redacted", incrementing ps6.envelope.detail_redacted_total{enricher_id} (catches dynamic strings lint cannot model; AC-11b). Both controls MUST be in place — neither alone is sufficient.

Registry layout (per-enricher files; directory-glob registration).

The registry is a Go package at internal/registry/ with the following layout:

internal/registry/
envelope.go # universal envelope, FailedSource, CacheMeta, TimingInfo (shared types)
registry.go # Register() API + global registry map
conformance.go # conformance runner (build-tag-gated; see "Conformance runner is test-only" below)
user/
rewards.go # init() registers (user, rewards) → RewardsPayload + caching policy + PII tags
shop.go # init() registers (user, shop) → ShopPayload + ...
offer/
offer_enrichment.go # PLT-446
retailer/
retailer_context.go # S1g
product/
offer_details.go # S1e
receipt/
ereceipts.go # S1d
restaurant/
restaurant_network.go # S1f
play/
play_context.go # S1b

Each internal/registry/{domain_type}/{enricher_id}.go file:

  1. Defines the Payload Go type for that (domain_type, enricher_id) pair, with PII tags per §5.2.
  2. Calls registry.Register(...) in init() to publish itself.
  3. Owns its caching policy, partial-failure mapping, and observability fixture.

Why per-file (not a single manifest.go): when S1b/d/e/f/g all declare conformance in the same week (FR-10 — born conformant), a single shared file scales merge conflicts linearly with vertical count. Per-file ownership means each vertical’s PR touches only files in its own {domain_type}/ subdirectory.

The conformance runner discovers all registered enrichers via the global registry populated by init() (Go’s directory-package import already does the globbing — no reflection needed). CI runs the runner against every registered entry independently; one vertical’s broken fixture does not block another’s PR.

Conformance runner is test-only.

The internal/registry/ conformance test runner MUST be gated by:

  1. Compile-time: build tag (e.g., //go:build conformance); production builds MUST NOT include the tag.
  2. Runtime fail-closed: the binary MUST inspect os.Getenv("ENVIRONMENT") (or equivalent platform marker) at startup; if value is prod/production AND the conformance handler is registered, the binary MUST refuse to start and emit ps6.startup.conformance_handler_in_prod_total (alert-paged).
  3. Metric emission: ps6.registry.conformance_violations_total is emitted at CI time only (build-tag-gated code path).

Build tag alone is insufficient — an attacker (or misconfigured CI) with deploy access can build with the tag and ship to prod. The runtime fail-closed check is the second control. Risk if both fail: a production-reachable conformance endpoint with elevated scope to test all enricher paths is an attacker bypass mechanism.

Observability mandates (FR-6):

Every enricher emits:

  • Span: enricher.{enricher_id} with attributes domain_type, domain_id_hash, principal_hash, status, cache_hit, version.
  • Metric: ps6.enricher.duration_seconds{enricher_id, status} (histogram).
  • Metric: ps6.enricher.requests_total{enricher_id, status, cache_hit} (counter).
  • Log: structured with enricher_id, domain_type, domain_id_hash, principal_hash, status, correlation_id, version.

Domain IDs and principals MUST be hashed (SHA-256 hex, first 16 chars) before emission to spans/logs/metrics — raw values stay only inside the data tier (FR-6). The hash function and prefix length are pinned so different services produce identical hashes and joins are possible across observability backends.

These are MUST. Additional spans/metrics are SHOULD (anything per-source, per-stage) and MAY (anything finer-grained).

Cross-section alignment:

  • PF8 (Feature Flag + Cross-Vertical Observability Conventions) owns the canonical metric naming convention and the Grafana panel/alert templates. The ps6.* namespace conforms to PF8’s naming rules; alert thresholds and dashboard panels for every ps6.* metric above MUST be defined in the PF8-owned Grafana folder, not in per-vertical dashboards.
  • PC5 (Agent CI/CD) owns the promotion pipeline that the §5.4 conformance runner (build-tag-gated) plugs into. Conformance failure (ps6.registry.conformance_violations_total > 0 at CI time) MUST gate promotion via PC5’s eval-gate config — no per-vertical override.
  • PF4 (Security & Auditability) owns the v2 audit-trail substrate referenced in R-10 (principal-mismatch + redaction events) and the R-9 v2 PII blocklist. PS6 events flow into PF4’s joined trace + SSE store rather than a PS6-private audit log.

Conformance testing:

internal/registry exposes a contract conformance test runner. New enrichers register a fixture; the runner verifies envelope shape, declared caching policy matches actual cache_meta values across N test calls, partial-failure shape matches at least one failure injection, observability spans/metrics emitted as required.

5.5 Worked example: a vertical team adopts PS6

Section titled “5.5 Worked example: a vertical team adopts PS6”

This walks through the S1b Play vertical adopting PS6 end-to-end. Shows what a vertical author writes vs what the PS6 framework provides for free.

What the framework gives you (envelope, principal, version, timing, observability, conformance):

Field / behaviorWho writes itNotes
envelope.enricher_idframeworkstamped from Register() argument
envelope.domain_typeframeworkstamped from Register() argument
envelope.principalframeworkcopied from EnricherRequest.Principal
envelope.versionframeworkread from registry entry’s declared schema version
envelope.timingframeworkmeasured around your Enrich() body
envelope.cache_meta.cached / ttl_secondsframeworkfilled from registry’s caching policy + cache hit/miss
Spans / metrics / logs (FR-6)frameworkemitted by the registry-wrapping decorator
Per-leaf-field lock_screen_safe taggingauthordeclared on the payload struct
envelope.payload (PlayPayload)authorthe actual enriched data
envelope.status + envelope.partial[]authorpopulated based on upstream-call outcomes
cache_meta.cacheable policy declarationauthorone-time, in the registry entry
Optional EnrichInternals() for S5authoronly if internal-only data needs to reach S5

So an author writes a payload struct, a caching policy declaration, and an Enrich() body. Everything else is plumbed by the framework.

The author’s internal/registry/play/play_context.go:

package play
import (
"context"
"time"
"github.com/fetch-rewards/consumer-context-service/internal/registry"
)
// PlayPayload is the external-facing payload for the Play vertical.
// Every leaf field MUST have a `lock_screen_safe` tag (§5.2).
type PlayPayload struct {
PlaySessionID string `json:"play_session_id" lock_screen_safe:"false"` // session ID — not lock-screen-safe
GameTitle string `json:"game_title" lock_screen_safe:"true"` // public game name
PointsAvailable int `json:"points_available" lock_screen_safe:"true"` // numeric, no PII
SponsorName string `json:"sponsor_name" lock_screen_safe:"true"` // public brand
EligibilityHint string `json:"eligibility_hint" lock_screen_safe:"false"` // eligibility leak risk
}
// PlayInternals carries S5-internal data. Has zero transport surface — never serialized.
// CI lint (AC-12) verifies no transport adapter imports this type.
type PlayInternals struct {
InternalEVScore float64 // assembler-only ranking input
UpstreamCorrelID string // for S5 debug joins, not external
AssemblyContext map[string]any
}
// init() registers (play, play_context) → PlayPayload with caching policy.
// No shared manifest file is edited — directory-glob registration via Go imports.
func init() {
registry.Register(registry.Entry{
DomainType: "play",
EnricherID: "play_context",
Version: "1.0.0",
PayloadType: PlayPayload{},
Caching: registry.CachingPolicy{
Cacheable: true,
TTL: 60 * time.Second,
ActorScoped: true, // payload depends on principal — cache key includes it
},
Enricher: enrichPlay,
InternalsEnricher: enrichPlayWithInternals, // optional; nil if no sidecar needed
})
}
// enrichPlay is the author's Enrich() body. Framework handles envelope/timing/observability.
func enrichPlay(ctx context.Context, req registry.EnricherRequest) (PlayPayload, registry.PartialState, error) {
// ... call upstreams, assemble PlayPayload, populate partial[] entries on non-critical failures.
// Return PlayPayload + the partial state; framework wraps both into EnricherResponse[PlayPayload].
}
func enrichPlayWithInternals(ctx context.Context, req registry.EnricherRequest) (PlayPayload, PlayInternals, registry.PartialState, error) {
// S5-only path. Same logic as enrichPlay but additionally returns PlayInternals.
// Framework wraps the (payload, partial) into the envelope; the internals are returned
// out-of-band as Internals[PlayInternals] — never serialized.
}

The author’s conformance fixture (internal/registry/play/play_context_test.go):

//go:build conformance
func TestPlayContextConformance(t *testing.T) {
fixture := registry.ConformanceFixture{
Entry: "(play, play_context)",
SampleSuccessRequest: registry.EnricherRequest{ /* ... */ },
SampleFailureInjections: []registry.FailureInjection{
{Source: "play_session_service", Critical: false},
{Source: "ps1", Critical: true}, // forces status=error per FR-5
},
}
registry.RunConformance(t, fixture)
}

The conformance runner (in internal/registry/, built under -tags conformance) verifies envelope shape, partial-failure shape against the injections, caching declaration matches observed cache_meta, observability emissions match FR-6 mandates, and lock_screen_safe tag presence on every leaf field of PlayPayload. If enrichPlayWithInternals is registered, CI lint also verifies no transport adapter package imports play.PlayInternals.

That’s the full surface a vertical team writes: one struct (PlayPayload), one optional internals struct (PlayInternals), one Register() call, one Enrich() body, one conformance fixture. ~80 lines of Go. The framework handles the universal envelope plumbing, observability, and conformance gating.

Impacted spec / systemEffect
PS1 (Knowledge Graph — consumer-graph-worker)PS6 enrichers read PS1 via the canonical service-to-service surface (PS1 §5.3.1). Schema changes in PS1 ripple into payload schemas; minor PS1 changes → enricher minor bump; major PS1 changes → enricher major bump.
consumer-agent (Labs agent — REST/MCP consumer)Receives the envelope as REST/MCP response body. Verifies envelope.principal against the invoking user before rendering payload (FR-11). Renders payload per-domain using the registry to resolve T.
Notification Service (DM delivery)Reads the envelope (from DM-pipeline queue or via direct call to CCS). Suppresses send on envelope.status="error" (FR-5 + AC-5). Verifies envelope.principal against notification recipient before delivery (FR-11). Transforms payload into the notification body via per-domain transformer.
S5 (Behavioral Assembly — Frank, Q2 vertical, internal/context)S5 is a consumer of PS6 enrichers. S5’s ShoppingReply schema is composed from multiple envelopes. S5 lives in the Verticals Spec Lab. May be retired or rewritten as the canonical reference assembler under PS6 once more verticals ship.
S1a–S1g (Q2 verticals — Frank’s epic)Each vertical’s enricher is a PS6 implementation. S1a + S1c retrofit (envelope wrap, partial-failure declaration); S1b/d/e/f/g declare conformance from day one.
S3 (rover-mcp Migration — Frank)Cross-cutting between PS1 and PS6. PS1 §5.4 owns the split rule; PS6 inherits “enrichment-shaped tools land here.”
Cadence enforcement (DM-pipeline layer in consumer-graph-worker’s scheduler)PS6 doesn’t own cadence; the scheduler that calls into PS6 owns it. PS6 enrichers’ actor-scope counter writes for cadence have the atomicity gap noted in §8 R-3.

PS6 has one blocking spec dependency (PS1 in consumer-graph-worker). The Notification Service and consumer-agent are downstream consumers — PS6 does not block on them, they inherit PS6’s contract.

Spec / SystemRelationshipWhat we need from itWhy
PS1 in consumer-graph-workerBlocking depThe canonical service-to-service read surface (PS1 §5.3.1) and the error taxonomy (PS1 §5.3.1)PS6 enrichers depend on PS1 for graph reads; can’t return typed payloads without typed graph reads; partial-failure reason values are mapped from PS1’s error classes
Notification Service (Labs DM delivery)Downstream consumer (PS6 blocks NS adoption of envelope)NS inherits PS6 envelope as the DM payload shapeNS verifies envelope.principal per FR-11; suppresses on status="error" per FR-5 + AC-5. PS6 author coordinates with the Notification Service team on transformer expectations
consumer-agent (Labs agent)Downstream consumerAgent unwraps EnricherResponse[T] using registry; verifies principal before renderingEach consumer-agent tool that calls CCS now receives a uniform envelope shape; agent renders per domain_type
DependencyOwnerStatusBlocker?
Steve Hollinger sign-off on W4, W5, W6, W7, W8Lab ownerOpenYes — wrong assumption invalidates §4 / §5
Notification Service team coordination (envelope adoption, per-domain transformer ownership)NS teamOpenNo — NS adopts when ready; PS6 ships independently with REST/MCP transports
PS1 spec drafted (sibling, in consumer-graph-worker repo)FrankIn flight (sibling PR in consumer-graph-worker)No — coordinated but each spec ships in its own repo
consumer-context-service shipping infrastructure (REST + MCP, FSD)Frank / CCS teamAvailableNo
#Risk / QuestionImpactMitigation / Answer
R-1v1 contract too narrow — S1f Restaurant or S1g Retailer doesn’t fit the envelopev2 needed within the quarter; existing enrichers retrofit againCaught early in S1f / S1g spec review; v2 acknowledged as future work
R-2Notification Service envelope adoption may divergeNS team adopts envelope but interprets status="error" suppression differently, or implements per-domain transformers inconsistentlyPS6 spec is the contract; NS team reviews + signs off before implementing transformers. Add NS contract test to PS6’s conformance suite (AC-5 generalized).
R-3Atomicity for actor-scope counters (cadence enforcement)PS6 enrichers writing per-user counters can race; cadence misses or double-countsCadence enforcement is owned by consumer-graph-worker’s scheduler, not PS6. PS6 references the gap; preferred mitigation: cadence layer reconciles from consumer-graph-worker’s scheduler audit trail.
R-4Partial-failure shape doesn’t capture real-world failure modesNew enricher invents a partial-failure shape that doesn’t fit the envelopeConformance test (AC-7) catches at PR time; v2 if unavoidable
R-5Caching infrastructure is per-enricher (no shared layer)Cache invalidation across enrichers (e.g., when PS1 schema changes) is manualv1 acknowledges; Q-1 below
R-6Observability mandates (FR-6) miss cases that matter for cadence/qualityProduction debugging painful for novel failure modesMandates are MUST-minimum; per-enricher additions are encouraged
R-7Envelope provenance is unsignedThe principal field is producer-asserted, not cryptographically bound. An attacker with code-exec inside consumer-context-service, write access to the DM-pipeline queue Notification Service reads, or the ability to inject into consumer-agent’s response path can forge an envelope with arbitrary principal matching the target — FR-11/AC-8 verification trivially passesv1: accepted residual risk — rely on network/IAM boundaries between PS6 and downstream consumers (CCS only writes envelopes; queue + response transport access is restricted). v2: HMAC signing over (enricher_id, version, principal, payload_hash, request_id, issued_at) keyed by per-service secret rotated daily; downstream consumers verify before forwarding/delivery; unsigned envelopes rejected. Labs security review to advise on v1↔v2 timing
R-8Per-(domain_type, enricher_id) actor_scoped is self-assertedFR-4 mandates principal in cache key when payload is actor-scoped, but the determination is left to the enricher author. An incorrect declaration silently pollutes cache cross-tenant; conformance test does not verifyv1: PR-review checklist — reviewer asserts each new enricher’s actor_scoped decision is correct; FR-4 makes the rule explicit. v2: registry entry declares actor_scoped: bool; conformance test injects two distinct principals against identical (domain_type, domain_id) and asserts payload divergence (or distinct cache keys) when actor_scoped=true. Default actor_scoped=true; opting out requires Labs security review
R-9lock_screen_safe tag is self-asserted; sensitive fields could be tagged true by mistakeAuthor adds email_address string and marks it lock_screen_safe=true (typo or misunderstanding). NS happily delivers the field on a lock screen. The §5.2 inversion closes the “internal data leaks through envelope” hole but does not close the “field is tagged wrong” holev1: §5.2 mandates the tag + AC-12 covers NS filtering correctness. v2: CI lint pattern-matches field names against a heuristic blocklist (email, phone, address, ssn, dob, free-text name, user_id outside hashed types) and fails the build when such a field is tagged lock_screen_safe=true without an explicit // nolint:lock-screen annotation with reviewer name. Coordinate with PF4 (Security & Auditability) on the blocklist source.
R-10No persistent forensic audit trail for security eventsMetrics ps6.envelope.principal_mismatch_total, ps6.envelope.detail_redacted_total, ps6.registry.conformance_violations_total are aggregated counters with no event-table backing. A SecOps investigation needs (enricher_id, principal, target, request_id, timestamp) per event, which counters cannot providev1: counters + alerts only (page on principal_mismatch). v2: principal-mismatch + redaction events flow into PF4 (Security & Auditability)‘s joined trace + SSE store rather than a PS6-private audit log — single SecOps query surface. Labs security review (via PF4) confirms retention requirements
R-11v1 contract may not survive S1d eReceiptsv1 was extracted from S1a (single-step Rewards) + S1c (multi-step Shop) + PLT-446 (Offer). S1d eReceipts has a very different shape (eventually-consistent ingest + receipt-level domain identity) — a real stress test of the envelope’s generalityv1.1 review gate after S1d ships. Cross-section retrospective: do partial[], cache_meta (especially actor_scoped keying), and the (domain_type, enricher_id) registry shape survive S1d? Gate is held before S1b/e/f/g implementations land so any contract change ripples to at most one shipped enricher (S1d), not five. Owner: PS6 author + S1d vertical lead.
Q-1Shared cache layer (cross-enricher) v1 or defer?Affects §5.4 caching contract; impacts cache-coherency story when PS1 schema changesDefer to v2; v1 mandates declaration only
Q-2Schema versioning — explicit version: "1.0" field, or content-addressed (hash of payload schema)?Affects §5.4 versioning protocolDefault: explicit semver per payload schema; reconsider if version-mismatch debugging proves painful
Q-3Idempotency key — implicit (domain_type, domain_id, context_vars) or explicit idempotency_key field?Affects FR-9 + Notification Service dedupv1 uses implicit; reconsider if downstream dedup proves brittle
Q-4Same domain_type with multiple enrichers (user for Rewards + Shop) — disambiguate via enricher_id only, or sub-type the domain?Affects registry shape, payload registry designv1: enricher_id disambiguates; sub-typing deferred
Q-5What happens when PS6 envelope conflicts with an upstream service’s existing response shape?Migration cost for shipped enrichers (S1a, S1c)Phase 2 retrofit (§10.1) wraps existing responses; no upstream-API changes
  • Unit tests (per enricher): payload assembly logic; partial-failure-injection tests verify status + partial[] populated correctly per upstream failure pattern; cache TTL respected; observability span/metric assertions.
  • Integration tests (real upstreams via CCS docker-compose): full enricher invocation against representative dataset; consumer-agent contract test (envelope returned as REST body → client unwrap by domain_type); Notification Service contract test (envelope → notification body decision; suppression on status="error").
  • Contract conformance tests (internal/registry): every registered enricher passes the conformance suite — envelope shape, caching declaration matches actual behavior, partial-failure shape conforms, observability mandates met (FR-6).
  • Schema drift tests (CI): registry artifact matches actual payload shapes; net-new domain types must update both code and artifact (NFR-4 + AC-4).
  • Performance tests: enricher p95 latency benchmarks against stage; envelope overhead < 512 bytes uncompressed when status=ok and partial=[] (NFR-2); worst-case bounded-size assertions (partial[] ≤ 16, detail ≤ 200 chars, upstream_ms ≤ 32 entries); partial-response overhead < 50ms (NFR-3).
  • Manual validation: deploy to stage; observe consumer-agent calling CCS and receiving envelopes; observe Notification Service reading envelopes from the DM-pipeline queue and applying suppression; verify per-vertical conformance for S1a Rewards + S1c Shop (the retrofit verticals) and PLT-446 (the born-conformant enricher).
  1. Phase 1 — Spec land + registry skeleton. PS6 spec merges; internal/registry/ skeleton created with envelope.go (universal envelope types), registry.go (Register API + global map), conformance.go (build-tag-gated runner), and empty {domain_type}/ subdirectories ready for per-enricher files. No enricher changes.

  2. Phase 2 — Retrofit shipped verticals. S1a Rewards + S1c Shop enrichers wrap existing responses in the envelope. Per-vertical 1–2 day exercise. Conformance tests run.

    Backward-compat protocol (Phase 2; carved out from §5.4 deprecation gate). The retrofit changes the response shape — a breaking API change for existing CCS clients. This is a one-shot retrofit migration, NOT subject to §5.4’s zero-metric deprecation gate (which applies to ongoing payload-schema evolution). The Phase 2 windows below are fixed calendar deadlines:

    • Envelope-shaped responses ship at versioned paths (e.g., /api/v2/rewards/{user_id} returns EnricherResponse[RewardsPayload]).
    • Existing endpoints (/api/v1/... or unversioned) continue returning the legacy DTO for 60 days post-Phase-2 merge. Critical security carve-out: legacy DTO endpoints MUST NOT be reachable from the Notification Service DM-pipeline path or from consumer-agent’s principal-verified rendering path — those consumers exclusively read versioned envelope-shaped paths (or refuse the response). Legacy endpoints serve only direct callers that have not migrated.
    • Clients migrate at their own pace within the 60-day window.
    • After 60 days, legacy endpoints emit a Deprecation: header on every response.
    • After 90 days, legacy endpoints return 410 Gone.
    • MCP tool definitions follow the same dual-endpoint pattern via tool versioning (e.g., get_rewards_v2 tool returns the envelope; get_rewards keeps the legacy shape until sunset).
    • Rationale for the carve-out from §5.4: §5.4’s “zero use for 7 consecutive days” gate is appropriate for ongoing schema evolution where consumers self-migrate; the Phase 2 retrofit is a one-time platform-wide change with explicit calendar windows so coordination across all CCS clients is bounded.
  3. Phase 3 — Born-conformant enricher (PLT-446 Offer). PLT-446 lands with PS6 conformance from day one. First end-to-end test of the contract on a new enricher.

  4. Phase 4 — Notification Service envelope adoption. NS team migrates DM-pipeline consumers from per-domain DTOs to EnricherResponse[T]; suppression logic switches to status="error" (FR-5 + AC-5); principal verification enabled (FR-11 + AC-8). NS-side per-domain transformers ship with PS6 v1’s payload registry as the source of truth.

  5. Phase 5 — Draft verticals declare conformance. S1b Play, S1d eReceipts, S1e Offer Details, S1f Restaurant, S1g Retailer specs each cite PS6 and pin their payload schemas. Implementations follow as their respective Q2 epics ship.

Phases 2–3 can run in parallel. Phases 4 + 5 trail.

  • ps6.enricher.requests_total{enricher_id, status, cache_hit} (counter)
  • ps6.enricher.duration_seconds{enricher_id, status} (histogram)
  • ps6.envelope.partial_total{enricher_id, source, critical} (counter — partial-failure rate per source, with critical bit)
  • ps6.envelope.error_total{enricher_id} (counter — full-failure rate)
  • ps6.envelope.principal_mismatch_total{consumer} (counter — security incident; should always be 0; spike → page)
  • ps6.envelope.deprecated_field_use_total{enricher_id, field} (counter — feeds the 7-consecutive-zero-day removal gate per §5.4)
  • ps6.notification.suppressed_critical_failure_total{enricher_id, source} (counter — Notification Service suppressions due to envelope status=“error” with a critical source listed in partial[])
  • ps6.registry.conformance_violations_total (counter — should always be 0; CI catches at PR time, NOT a runtime metric)
  • ps6.envelope.version_mismatch_total{enricher_id} (counter — consumer reading older envelope version than enricher emits)

Alerts (deduplicated to avoid double-paging on a single root cause):

  • enricher.duration_seconds p95 > NFR-1 thresholds for 10 minutes → page (per enricher).
  • partial_total{critical=true} > 0 → page (critical-source failure; Notification Service is suppressing). This is the canonical signal for critical failures; error_total spikes co-incident with this should be deduplicated by the alert system.
  • partial_total{critical=false} rate spike > 5% over 1 hour → notify (likely upstream issue with the named source).
  • error_total rate > 1% over 15 minutes → page excluding events already paged via partial_total{critical=true} (to avoid double-paging on critical-source failures, which trigger both metrics by FR-5).
  • principal_mismatch_total > 0 → page (security incident — possible cache poisoning, stale envelope, or trust-boundary bug).
  • detail_redacted_total > 0 → notify (FR-12 runtime redactor caught a leak the CI lint missed; investigate the source).
  • version_mismatch_total > 0 → notify (downstream consumer not updated to current envelope version).
  • Phase 5 → 4: new vertical specs revert; PS6 still has S1a + S1c + PLT-446 conformant.
  • Phase 4 → 3: Notification Service reverts to pre-spec DTO consumption; PS6 envelopes still emitted on REST/MCP transports but NS doesn’t consume them yet.
  • Phase 3 → 2: PLT-446 reverts (or ships without envelope wrap as a tactical fallback).
  • Phase 2 → 1: S1a / S1c revert envelope wrap; existing per-domain DTOs restored.
  • Phase 1 → pre-spec: delete internal/registry/; no behavior change, just spec retraction.

Hard fall-back is the pre-spec fragmented state, which is acceptable because pre-spec verticals were operating in it.

  • W4: PS6 = consumer-context-service. (Self-evident — spec lives in the CCS repo.)
  • W5: v1 contract from S1a + S1c shipped + PLT-446 in flight; S1b/d/e/f/g conform from day one. (Steve to confirm.)
  • W6: Single-enricher contract only; S5 is a vertical consumer (lives in internal/context). (Steve to confirm.)
  • W7: Black-box pipeline. (Steve to confirm.)
  • W8: Universal envelope + typed per-domain payload. (Steve to confirm.)
  • W10: Envelope principal field + cross-section authz verification by downstream consumers (consumer-agent, Notification Service). (Review-derived; no external sign-off required.)
  • W11: Envelope = external-facing data only; per-field lock_screen_safe boolean drives NS lock-screen filter; internal-only data lives in the optional EnrichInternals() sidecar with zero transport surface. (Review-derived from Steve’s review of PR #77 first round; refined from the original per-channel-allowlist scheme to close the “leak by adding a field” failure mode.)
  • S1a Rewards / PointPass Context ✅ — retrofit to envelope in Phase 2.
  • S1c Shop Context ✅ — retrofit to envelope in Phase 2.
  • S1b Play, S1d eReceipts, S1e Offer Details, S1f Restaurant, S1g Retailer (drafts) — declare PS6 conformance from day one (W5).
  • S5 Behavioral Assembly (Frank, Q2 vertical) — vertical consumer of PS6; lives in Verticals Spec Lab.
  • PLT-446 Offer Enrichment Pipeline — first born-conformant enricher under PS6; replaces the discover-cache dependency.
  • consumer-context-service/internal/products — Product enrichment (closest existing PS6-shaped enricher).
  • consumer-context-service/internal/offers — Offer enrichment (PLT-446 lives here).
  • consumer-context-service/internal/users — User enrichment (S1a Rewards in spirit).
  • consumer-context-service/internal/shopping — S1c Shop.
  • consumer-context-service/internal/context — Cross-domain orchestrator (S5-shaped, not strictly PS6).
  • consumer-context-service/internal/cache — Generic TTL cache.
  • consumer-context-service/internal/api — REST handlers.
  • consumer-context-service/internal/mcp — MCP tool definitions.
  • consumer-context-service/specs/001-context-verticals-and-proactive-nudges — Q2 S1 epic spec (Frank).
  • consumer-context-service/specs/002-behavioral-context-assembly-search — S5 spec.